Skip to content

Conversation

@ewowi
Copy link
Collaborator

@ewowi ewowi commented Feb 10, 2026

Summary by CodeRabbit

  • New Features
    • System-wide IMU support: gyro/accelerometer/gravity available to effects and nodes; gyro control always present.
    • I²C bus initialization with device scanning and frequency reporting in the I/O module.
  • Behavior Changes
    • Node state changes now invoke per-node update hooks for more immediate updates.
  • Dependencies
    • Added MPU6050 motion sensor library.

pio.ini: add MPU6050 driver

backend
=======
- NodeManager: send onUpdate to node when on/off changed
- Nodes: add sharedData.gravity, add D_MPU6050.h
- Module Drivers: add D_MPU6050.h
- D_MPU6050.h: New
- Effects: particles: add gyro
@coderabbitai
Copy link

coderabbitai bot commented Feb 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds MPU6050 IMU support, exposes gravity in shared data, registers IMU driver and triggers per-node init under mutex, removes compile-time gyro guards in effects, adds I2C initialization and device scanning, and updates platformio dependencies.

Changes

Cohort / File(s) Summary
Platform config
platformio.ini
Added ElectronicCats/MPU6050 @ 1.3.0 to lib_deps; minor formatting and comment additions.
IMU driver (new)
src/MoonLight/Nodes/Drivers/D_MPU6050.h
New IMUDriver implementing MPU6050 DMP: init, FIFO reads, quaternion→gravity/ypr, exposes gyro/accel, writes sharedData.gravity, provides layout/loop hooks.
Driver registration & node init
src/MoonLight/Modules/ModuleDrivers.h, src/MoonBase/NodeManager.h
Register IMUDriver in drivers list and call nodeClass->onUpdate(...) under node-layer mutex when enabling a node to trigger per-node initialization.
Shared data & headers
src/MoonBase/Nodes.h
Added Coord3D gravity to SharedData and included IMU driver header in drivers include block.
Effects (gyro handling)
src/MoonLight/Nodes/Effects/E_MoonLight.h
Removed conditional compilation around gyro flag (now always present); effect reads gravity from sharedData.gravity when gyro enabled.
I2C & Module IO
src/MoonBase/Modules/ModuleIO.h
Added Wire integration, SDA/SCL pin detection, i2cFreq/i2cBus controls, updateDevices() scanning, and Wire.setClock handling on config changes.
Minor API consistency
src/MoonLight/Nodes/Drivers/D__Sandbox.h, src/MoonLight/Nodes/Effects/E__Sandbox.h
Added override specifier to example onUpdate/effect methods for correctness.

Sequence Diagram

sequenceDiagram
    participant NM as NodeManager
    participant MD as ModuleDrivers
    participant IMU as IMUDriver
    participant I2C as I2C Bus
    participant SD as SharedData
    participant Effect as E_MoonLight

    NM->>MD: request node allocation (node.on = true)
    MD->>IMU: instantiate IMUDriver
    NM->>IMU: nodeClass->onUpdate(oldValue, nodeState)
    IMU->>I2C: init / probe MPU6050 (Wire)
    I2C-->>IMU: ACK / device bytes
    IMU->>IMU: DMP init & calibration
    loop every 20ms
        IMU->>I2C: read FIFO packet
        I2C-->>IMU: sensor packet
        IMU->>IMU: compute quaternion → gravity & euler
        IMU->>SD: update sharedData.gravity
    end
    Effect->>SD: read sharedData.gravity
    SD-->>Effect: gravity vector
    Effect->>Effect: apply gravity to rendering
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Add node mutex  #103: introduces per-node mutex wrapping and mutex-guarded onUpdate/onLayout — closely related to NodeManager changes.
  • prep71 #106: modifies ModuleState updatedItem/mutex/updatePending semantics — overlaps with per-node update/mutex interactions.
  • v0.7.0 preparations #76: refactors driver registration and node-allocation flows — relates to IMU driver registration/allocation.

Poem

🐰
I woke to sensors, six axes bright,
Gravity tucked in shared-data light,
No guards to hide the gyro's glow,
I2C hummed and numbers flow,
MoonLight hops — the rabbit codes tonight.

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The PR title 'Tube gyro pir' is vague and does not clearly convey the main changes; it appears to be a shorthand or placeholder rather than a descriptive summary. Consider using a more descriptive title that reflects the primary objective, such as 'Add MPU6050 IMU support with centralized I2C management' or 'Integrate IMU/gyro and PIR sensors with ModuleIO I2C handling'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch tube-pir-gyro

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@src/MoonBase/NodeManager.h`:
- Line 267: The call to nodeClass->onUpdate currently passes the full nodeState
without holding layerMutex and thus is inconsistent with the controls-value
path; wrap the on-toggle path's nodeClass->onUpdate call with layerMutex (same
scope used in the controls-value path) and normalize the argument semantics by
passing a consistent control object (e.g., construct a single-control JSON
containing only the on field or an explicit {"on": ...} object) instead of the
full nodeState so onUpdate implementations always receive the same JSON shape.

In `@src/MoonLight/Nodes/Drivers/D_MPU6050.h`:
- Line 46: The onUpdate method declaration lacks the override specifier which
risks silent signature drift; update the declaration of void onUpdate(const
Char<20>& oldValue, const JsonObject& control) in class D_MPU6050 to add the
override keyword so it matches the other overridden methods (e.g., setup,
hasOnLayout, onLayout, loop20ms) and lets the compiler validate the override.
- Around line 37-44: The method initI2S incorrectly initializes I2C (Wire.begin)
so rename the function to initI2C (and update all callers/usages) and update the
log message (EXT_LOGI) to reflect "initI2C" to avoid protocol confusion; while
here also remove or replace the hardcoded pins (Wire.begin(5, 6)) by using the
existing pin allocation mechanism or configurable pin variables (e.g., use
allocatePin or pass sda/scl parameters or read configured pins) so the function
no longer hardcodes 5 and 6 and will work across boards.
- Around line 83-84: The hasOnLayout() override currently returns true while
onLayout() is an empty override, causing unnecessary mapping-system calls;
either remove both overrides (delete the hasOnLayout() and onLayout() methods so
the node uses the base-class default of no layout handling) or implement real
layout initialization/update logic inside onLayout() and keep hasOnLayout()
true; locate the D_MPU6050 class methods hasOnLayout() and onLayout() and apply
one of these two changes (prefer removing both overrides if the driver does not
need to react to layout changes).
🧹 Nitpick comments (3)
src/MoonLight/Nodes/Drivers/D_MPU6050.h (2)

26-26: Dead code: gravityVector (public) and devStatus (private member) are unused.

  • gravityVector (line 26) is declared but never written or read. The private gravity (line 135) is the actual gravity vector used in loop20ms. Either remove gravityVector or use it.
  • The member devStatus (line 127) is shadowed by a local variable in onUpdate (line 56) and never referenced elsewhere.
Remove dead declarations
   Coord3D gyro;  // in degrees (not radians)
   Coord3D accell;
-  VectorFloat gravityVector;
 private:
   MPU6050 mpu;
 
   // MPU control/status vars
-  uint8_t devStatus;       // return status after each device operation (0 = success, !0 = error)
   uint8_t fifoBuffer[64];  // FIFO storage buffer

Also applies to: 127-127


46-81: No handling when node is turned off — MPU6050 DMP stays enabled.

onUpdate initializes and enables the DMP when control["on"] == true, but there's no branch for control["on"] == false. When the user disables this driver node, the DMP keeps running and loop20ms just returns early due to motionTrackingReady staying true. Consider disabling DMP and resetting motionTrackingReady on turn-off to save power/bus bandwidth and zero out sharedData.gravity.

Proposed off-handling
   void onUpdate(const Char<20>& oldValue, const JsonObject& control) override {
     if (!control["on"].isNull()) {
       if (control["on"] == true) {
         if (initI2C()) {
           // ... existing init code ...
         }
+      } else {
+        // Disable DMP and reset state
+        if (motionTrackingReady) {
+          mpu.setDMPEnabled(false);
+          motionTrackingReady = false;
+          sharedData.gravity = Coord3D();
+        }
       }
     }
   }
platformio.ini (1)

144-145: MPU6050 dependency is added to [env] (global) — consider scoping to [moonlight] if only MoonLight builds use it.

Placing ElectronicCats/MPU6050 @ 1.3.0 under [env] lib_deps means it's pulled and linked for every board variant, even those without MPU6050 hardware or the FT_MOONLIGHT feature flag. If this library is only used in MoonLight builds, moving it to the [moonlight] section (as done with FastLED and other specialized dependencies) would avoid unnecessary binary bloat for other firmware variants.

Also note: line 144 uses 2 spaces for indentation while line 143 uses a tab — consider standardizing to match surrounding entries.

if (nodeClass != nullptr) {
nodeClass->on = updatedItem.value.as<bool>(); // set nodeclass on/off
// EXT_LOGD(ML_TAG, " nodeclass 🔘:%d 🚥:%d 💎:%d", nodeClass->on, nodeClass->hasOnLayout(), nodeClass->hasModifier());
nodeClass->onUpdate(updatedItem.oldValue, nodeState); // custom onUpdate for the node
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Inconsistent mutex handling and argument semantics for onUpdate.

Two observations:

  1. No mutex: The controls-value path (line 282–285) holds layerMutex around onUpdate, but this new on-toggle path does not. For MPU6050Driver the hardware init may not need it, but other nodes' onUpdate could mutate shared layer state. Consider wrapping consistently:
+            xSemaphoreTake(*nodeClass->layerMutex, portMAX_DELAY);
             nodeClass->onUpdate(updatedItem.oldValue, nodeState);  // custom onUpdate for the node
+            xSemaphoreGive(*nodeClass->layerMutex);
  1. Argument type mismatch: Here nodeState (full node JSON with name, on, controls) is passed as the control parameter, while at line 284 a single control object is passed. The MPU6050Driver relies on checking control["on"] to distinguish, but this convention is implicit and fragile — a future node's onUpdate could misinterpret the JSON shape.
🤖 Prompt for AI Agents
In `@src/MoonBase/NodeManager.h` at line 267, The call to nodeClass->onUpdate
currently passes the full nodeState without holding layerMutex and thus is
inconsistent with the controls-value path; wrap the on-toggle path's
nodeClass->onUpdate call with layerMutex (same scope used in the controls-value
path) and normalize the argument semantics by passing a consistent control
object (e.g., construct a single-control JSON containing only the on field or an
explicit {"on": ...} object) instead of the full nodeState so onUpdate
implementations always receive the same JSON shape.

Comment on lines 37 to 44
bool initI2S() {
// tbd: set pins in ui!!
// allocatePin(21, "Pins", "I2S SDA");
// allocatePin(22, "Pins", "I2S SCL");
bool success = Wire.begin(5, 6);
EXT_LOGI(ML_TAG, "initI2S Wire begin %s", success ? "success" : "failure");
return success;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Method is named initI2S but initializes I2C (Wire/Wire.begin).

I²S (Inter-IC Sound) and I²C (Inter-Integrated Circuit / Wire) are different protocols. This should be initI2C to avoid confusion.

Also, I2C pins 5 and 6 are hardcoded — acknowledged by the TODO, but worth noting this will fail on many boards.

Proposed rename
-  bool initI2S() {
-    // tbd: set pins in ui!!
-    // allocatePin(21, "Pins", "I2S SDA");
-    // allocatePin(22, "Pins", "I2S SCL");
+  bool initI2C() {
+    // tbd: set pins in ui!!
+    // allocatePin(21, "Pins", "I2C SDA");
+    // allocatePin(22, "Pins", "I2C SCL");
     bool success = Wire.begin(5, 6);
-    EXT_LOGI(ML_TAG, "initI2S Wire begin %s", success ? "success" : "failure");
+    EXT_LOGI(ML_TAG, "initI2C Wire begin %s", success ? "success" : "failure");
     return success;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
bool initI2S() {
// tbd: set pins in ui!!
// allocatePin(21, "Pins", "I2S SDA");
// allocatePin(22, "Pins", "I2S SCL");
bool success = Wire.begin(5, 6);
EXT_LOGI(ML_TAG, "initI2S Wire begin %s", success ? "success" : "failure");
return success;
}
bool initI2C() {
// tbd: set pins in ui!!
// allocatePin(21, "Pins", "I2C SDA");
// allocatePin(22, "Pins", "I2C SCL");
bool success = Wire.begin(5, 6);
EXT_LOGI(ML_TAG, "initI2C Wire begin %s", success ? "success" : "failure");
return success;
}
🤖 Prompt for AI Agents
In `@src/MoonLight/Nodes/Drivers/D_MPU6050.h` around lines 37 - 44, The method
initI2S incorrectly initializes I2C (Wire.begin) so rename the function to
initI2C (and update all callers/usages) and update the log message (EXT_LOGI) to
reflect "initI2C" to avoid protocol confusion; while here also remove or replace
the hardcoded pins (Wire.begin(5, 6)) by using the existing pin allocation
mechanism or configurable pin variables (e.g., use allocatePin or pass sda/scl
parameters or read configured pins) so the function no longer hardcodes 5 and 6
and will work across boards.

return success;
}

void onUpdate(const Char<20>& oldValue, const JsonObject& control) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Missing override keyword on onUpdate.

All other overridden methods in this class (setup, hasOnLayout, onLayout, loop20ms, destructor) use override, but onUpdate does not. Without override, the compiler won't catch signature drift if the base class changes.

Fix
-  void onUpdate(const Char<20>& oldValue, const JsonObject& control) {
+  void onUpdate(const Char<20>& oldValue, const JsonObject& control) override {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void onUpdate(const Char<20>& oldValue, const JsonObject& control) {
void onUpdate(const Char<20>& oldValue, const JsonObject& control) override {
🤖 Prompt for AI Agents
In `@src/MoonLight/Nodes/Drivers/D_MPU6050.h` at line 46, The onUpdate method
declaration lacks the override specifier which risks silent signature drift;
update the declaration of void onUpdate(const Char<20>& oldValue, const
JsonObject& control) in class D_MPU6050 to add the override keyword so it
matches the other overridden methods (e.g., setup, hasOnLayout, onLayout,
loop20ms) and lets the compiler validate the override.

@ewowi
Copy link
Collaborator Author

ewowi commented Feb 10, 2026

@coderabbitai, the mcu6050 is not working, I wired the scanner/scl/gnd/vcc correctly. got the following logging, what is wrong?

[ 56027][D][NodeManager.h:262] onUpdate(): [💫] httpd (1524) MPU6050 driver ☸️ on: true (#2)
[ 56028][W][Wire.cpp:300] begin(): Bus already started in Master Mode.
[ 56028][I][D_MPU6050.h:42] initI2S(): [💫] httpd (1396) initI2S Wire begin success
m
0m
ALID_STATE

ransmit_receive failed: [259] ESP_ERR_INVALID_STATE
eturned Error 259
[ 56232][E][esp32-hal-i2c-ng.c:369] i2cWriteReadNonStop(): i2c_master_transmit_receive failed: [259] ESP_ERR_INVALID_STATE
[ 56233][E][Wire.cpp:520] requestFrom(): i2cWriteReadNonStop returned Error 259
transmit failed: [259] ESP_ERR_INVALID_STATE
[ 56334][E][esp32-hal-i2c-ng.c:369] i2cWriteReadNonStop(): i2c_master_transmit_receive failed: [259] ESP_ERR_INVALID_STATE
[ 56335][E][Wire.cpp:520] requestFrom(): i2cWriteReadNonStop returned Error 259
[ 56335][I][D_MPU6050.h:77] onUpdate(): [💫] httpd (848) Testing device connections MPU6050 connection failed

@coderabbitai
Copy link

coderabbitai bot commented Feb 10, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

# Read the MPU6050 driver implementation
echo "=== D_MPU6050.h implementation ==="
cat src/MoonLight/Nodes/Drivers/D_MPU6050.h

echo -e "\n\n=== Searching for Wire.begin() calls ==="
rg -n "Wire\.begin" --type cpp --type-add 'cpp:*.{h,cpp,ino}'

echo -e "\n\n=== Searching for other I2C initializations ==="
rg -n "Wire\." --type cpp --type-add 'cpp:*.{h,cpp,ino}' -C 2 | head -100

Length of output: 6271


@ewowi, I found the issue! The error "Bus already started in Master Mode" indicates that the I2C Wire interface is already initialized somewhere else (possibly by the ESP32 Arduino framework with default pins), and then your driver is trying to call Wire.begin(5, 6) again with different pins. This causes the ESP_ERR_INVALID_STATE errors.

The Problem

When Wire.begin() is called on an already-initialized I2C bus with different pin assignments, the ESP32 I2C driver gets into an invalid state and all subsequent I2C operations fail.

The Solution

You need to end the existing I2C bus before reinitializing it with your custom pins. Modify the initI2S() method in D_MPU6050.h:

bool initI2S() {
  // tbd: set pins in ui!!
  // allocatePin(21, "Pins", "I2S SDA");
  // allocatePin(22, "Pins", "I2S SCL");
  
  // End any existing I2C bus before reinitializing
  Wire.end();
  delay(10);  // Small delay to ensure bus is fully released
  
  bool success = Wire.begin(5, 6);
  EXT_LOGI(ML_TAG, "initI2S Wire begin %s", success ? "success" : "failure");
  return success;
}

The Wire.end() call will properly shut down the existing I2C bus, allowing you to reinitialize it with your custom pins (5 and 6).

Additional Checks

Also verify:

  1. Pin 5 = SDA, Pin 6 = SCL - Make sure your physical wiring matches (some boards vary)
  2. Pull-up resistors - Ensure you have 4.7kΩ pull-ups on both SDA and SCL lines (some MPU6050 modules have them built-in, some don't)
  3. Power supply - Verify the MPU6050 is getting clean 3.3V power

Try the Wire.end() fix first - this should resolve the state error!

@ewowi
Copy link
Collaborator Author

ewowi commented Feb 10, 2026

@coderabbitai, regarding

indicates that the I2C Wire interface is already initialized somewhere else (possibly by the ESP32 Arduino framework with default pins)
Could you check the repo where that could have been done?

Wire.end(); delay(10);

Improved it a bit but still errors, please investigate:

[ 69729][D][NodeManager.h:262] onUpdate(): [💫] httpd (1524) MPU6050 driver ☸️ on: true (#2)
[ 69740][I][esp32-hal-i2c-ng.c:112] i2cInit(): Initializing I2C Master: num=0 sda=5 scl=6 freq=100000
[ 69741][I][D_MPU6050.h:44] initI2S(): [💫] httpd (1284) initI2S Wire begin success
dNonStop returned Error 259
[ 69843][E][esp32-hal-i2c-ng.c:369] i2cWriteReadNonStop(): i2c_master_transmit_receive failed: [259] ESP_ERR_INVALID_STATE
[ 69844][E][Wire.cpp:520] requestFrom(): i2cWriteReadNonStop returned Error 259
_transmit_receive failed: [259] ESP_ERR_INVALID_STATE
m(): i2cWriteReadNonStop returned Error 259
TATE
m
m
_ERR_INVALID_STATE
259
ed

@coderabbitai
Copy link

coderabbitai bot commented Feb 10, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

echo "=== Searching for all Wire.begin() calls in the codebase ==="
rg -n "Wire\.begin" --type-add 'code:*.{h,cpp,ino,c}' --type code

echo -e "\n\n=== Searching for any I2C initialization patterns ==="
rg -n "I2C|i2c" --type-add 'code:*.{h,cpp,ino,c}' --type code -g '!*.json' -g '!*.md' | grep -i "init\|begin\|start" | head -50

echo -e "\n\n=== Looking for other driver files that might use I2C ==="
fd -e h -e cpp "D_.*\.h" src/

echo -e "\n\n=== Checking MPU6050 driver for address and initialization sequence ==="
cat src/MoonLight/Nodes/Drivers/D_MPU6050.h | grep -A 5 -B 5 "initialize\|setDeviceID\|0x68\|0x69"

echo -e "\n\n=== Searching for MPU6050 related configurations ==="
rg -n "MPU6050" --type-add 'code:*.{h,cpp,ino,c}' --type code -C 2

Length of output: 5209


🌐 Web query:

ESP32 I2C ESP_ERR_INVALID_STATE error 259 causes

💡 Result:

259 is ESP_ERR_INVALID_STATE (0x103). In ESP-IDF/Arduino-ESP32 I2C, it generally means “the I2C driver/state machine isn’t in a usable state for this call.” Common causes:

  1. Driver not installed / not initialized for that port
  • Calling i2c_master_*() (or Arduino Wire.requestFrom()/read()) before the port has been set up with i2c_param_config() + i2c_driver_install() (or before Wire.begin(...)). The ESP-IDF I2C docs explicitly require installing the driver before doing master transactions. [1]
  • Internally, ESP-IDF checks that the per-port driver object exists (e.g., p_i2c_obj[i2c_num] != NULL); if it’s NULL, functions return ESP_ERR_INVALID_STATE. [2]
  1. Driver was deleted/deinitialized (or never reinstalled)
  • Calling I2C APIs after i2c_driver_delete() (or Arduino Wire.end() / a library deinit) without reinstalling/reinitializing first can lead to ESP_ERR_INVALID_STATE. [2]
  1. Wrong I2C port / resource conflict
  • Using the wrong i2c_port_t (ESP32 typically has two controllers) or having two components both trying to own/reconfigure the same I2C port can leave one caller operating on an uninstalled or torn-down driver state (manifesting as INVALID_STATE). (The “driver per port must be installed before use” requirement is the key underlying constraint.) [1]
  1. Arduino-ESP32 “i2c-ng” regression/bug (especially when upgrading cores)
  • There are reports on Arduino-ESP32 3.x (new I2C implementation) where transactions start failing and then i2c_master_receive/i2cRead() begin returning [259] ESP_ERR_INVALID_STATE after running for a while. If your setup is correct, this can be a core/driver issue rather than your wiring. [3]

Sources:
[1] ESP-IDF I2C driver docs (install driver before master comms). (docs.espressif.com)
[2] Notes showing ESP_ERR_INVALID_STATE returned when the I2C driver object for the port is NULL (not installed / deleted). (gist.github.com)
[3] Arduino-ESP32 issue reporting i2c_master_receive / i2cRead() failing with [259] ESP_ERR_INVALID_STATE after some runtime (3.x). (github.com)

Citations:


@ewowi, Good news: I found no other Wire/I2C initialization in the entire codebase - only your MPU6050 driver initializes I2C.

The Real Problem

Based on the logs and research, the issue is likely that Wire.end() is causing the problem, not solving it. Here's why:

ESP_ERR_INVALID_STATE occurs when the I2C driver isn't properly installed or was deleted/deinitialized. When you call Wire.end(), it deletes the I2C driver. Then Wire.begin() succeeds in starting to initialize it, but the driver may not be in a fully usable state yet when mpu.testConnection() tries to communicate immediately after.

Solution: Remove Wire.end() and Add Proper Delay

Try this updated initI2S():

bool initI2S() {
  // tbd: set pins in ui!!
  // allocatePin(21, "Pins", "I2S SDA");
  // allocatePin(22, "Pins", "I2S SCL");
  
  // Don't call Wire.end() - it deletes the driver and causes INVALID_STATE
  bool success = Wire.begin(5, 6);
  EXT_LOGI(ML_TAG, "initI2S Wire begin %s", success ? "success" : "failure");
  
  if (success) {
    delay(100);  // Give I2C bus time to stabilize
  }
  
  return success;
}

Verify Hardware with I2C Scanner

Before testing again, let's verify the MPU6050 is actually present and at the correct address. Add this temporary diagnostic function:

void scanI2C() {
  EXT_LOGI(ML_TAG, "Scanning I2C bus...");
  byte count = 0;
  for (byte i = 1; i < 127; i++) {
    Wire.beginTransmission(i);
    if (Wire.endTransmission() == 0) {
      EXT_LOGI(ML_TAG, "Found I2C device at address 0x%02X", i);
      count++;
    }
  }
  EXT_LOGI(ML_TAG, "Found %d device(s)", count);
}

Call it in onUpdate() right after initI2S() succeeds:

if (initI2S()) {
  scanI2C();  // Diagnostic - remove after testing
  mpu.initialize();
  // ... rest of code

Hardware Checklist

The MPU6050 should appear at 0x68 or 0x69 (depending on AD0 pin). Verify:

  1. Wiring:

    • GPIO 5 → SDA
    • GPIO 6 → SCL
    • GND → GND
    • 3.3V → VCC
  2. Pull-up resistors: 4.7kΩ on both SDA and SCL (check if your module has them built-in)

  3. Power: Clean 3.3V supply with stable ground

Let me know what addresses the scanner finds!

@ewowi
Copy link
Collaborator Author

ewowi commented Feb 10, 2026

@coderabbitai, regarding

Let me know what addresses the scanner finds!

[ 18026][I][esp32-hal-i2c-ng.c:112] i2cInit(): Initializing I2C Master: num=0 sda=5 scl=6 freq=100000
[ 18027][I][D_MPU6050.h:44] initI2C(): [💫] httpd (1284) initI2C Wire begin success
[ 18128][I][D_MPU6050.h:54] scanI2C(): [💫] httpd (1284) Scanning I2C bus...
[ 24429][I][D_MPU6050.h:63] scanI2C(): [💫] httpd (1284) Found 0 device(s)
[ 24480][E][esp32-hal-i2c-ng.c:369] i2cWriteReadNonStop(): i2c_master_transmit_receive failed: [259] ESP_ERR_INVALID_STATE
[ 24481][E][Wire.cpp:520] requestFrom(): i2cWriteReadNonStop returned Error 259
[ 24531][E][esp32-hal-i2c-ng.c:369] i2cWriteReadNonStop(): i2c_master_transmit_receive failed: [259] ESP_ERR_INVALID_STATE
[ 24532][E][Wire.cpp:520] requestFrom(): i2cWriteReadNonStop returned Error 259
[ 24583][E][esp32-hal-i2c-ng.c:369] i2cWriteReadNonStop(): i2c_master_transmit_receive failed: [259] ESP_ERR_INVALID_STATE
[ 24584][E][Wire.cpp:520] requestFrom(): i2cWriteReadNonStop returned Error 259
[ 24634][E][esp32-hal-i2c-ng.c:369] i2cWriteReadNonStop(): i2c_master_transmit_receive failed: [259] ESP_ERR_INVALID_STATE
[ 24635][E][Wire.cpp:520] requestFrom(): i2cWriteReadNonStop returned Error 259
[ 24685][E][esp32-hal-i2c-ng.c:275] i2cWrite(): i2c_master_transmit failed: [259] ESP_ERR_INVALID_STATE
[ 24736][E][esp32-hal-i2c-ng.c:369] i2cWriteReadNonStop(): i2c_master_transmit_receive failed: [259] ESP_ERR_INVALID_STATE
[ 24737][E][Wire.cpp:520] requestFrom(): i2cWriteReadNonStop returned Error 259
[ 24737][I][D_MPU6050.h:99] onUpdate(): [💫] httpd (916) Testing device connections MPU6050 connection failed

@coderabbitai
Copy link

coderabbitai bot commented Feb 10, 2026

Tip

For best results, initiate chat on the files or code changes.

@ewowi, The scanner found 0 devices, which means the MPU6050 isn't responding at all on the I2C bus. The root cause is likely GPIO pin selection. GPIO 5 is a strapping pin on ESP32, which must be high (1) during boot. Using strapping pins for I2C can cause initialization and communication issues.

Critical Issue: GPIO Pin Choice

ESP32 allows any GPIO pin to be configured as I2C SDA and SCL, but the default I2C pins for ESP32 with Arduino IDE are GPIO 22 (SCL) and GPIO 21 (SDA). These are the most reliable pins for I2C communication.

Immediate Solution: Use Standard I2C Pins

Change your initI2C() function to use the standard pins:

bool initI2C() {
  // Use standard ESP32 I2C pins
  // GPIO 21 = SDA, GPIO 22 = SCL
  bool success = Wire.begin(21, 22);
  EXT_LOGI(ML_TAG, "initI2C Wire begin %s", success ? "success" : "failure");
  
  if (success) {
    delay(100);  // Give I2C bus time to stabilize
  }
  
  return success;
}

Rewire your MPU6050:

  • GPIO 21 → SDA
  • GPIO 22 → SCL
  • GND → GND
  • 3.3V → VCC

Additional Troubleshooting

If the scanner still finds 0 devices after switching pins:

  1. Check power with multimeter: Verify the MPU6050 VCC pin has 3.3V
  2. Verify connections: Use a continuity tester to ensure wires are properly connected
  3. Test MPU6050 on another device: Try connecting it to an Arduino/Teensy with a working I2C scanner
  4. Check for shorts: Ensure SDA and SCL aren't shorted together or to GND/VCC
  5. Use pull-up resistors of 3.3kΩ or 4.7kΩ if your MPU6050 module doesn't have them built-in

The fact that the scanner took 6+ seconds and then failed with ESP_ERR_INVALID_STATE suggests the I2C bus couldn't communicate at all — typically a sign of wrong pins, disconnected hardware, or a dead module.

Try the pin change first and let me know what the scanner finds!

@ewowi
Copy link
Collaborator Author

ewowi commented Feb 10, 2026

@coderabbitai,
I use this board: https://www.seeedstudio.com/XIAO-ESP32S3-p-5627.html

Which has pin 5 and 6, please check their website and let me know if 5,6 is indeed right

@coderabbitai
Copy link

coderabbitai bot commented Feb 10, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🌐 Web query:

Seeed XIAO ESP32S3 I2C pins GPIO pinout

💡 Result:

For the Seeed Studio XIAO ESP32S3 / XIAO ESP32S3 Sense, the default I2C pins are:

  • SDA → GPIO5
  • SCL → GPIO6 [1][2][3]

Note: the Sense camera module uses a different I2C pair:

  • Camera SDA → GPIO40
  • Camera SCL → GPIO39 [4]

Sources: [1] [2] [3] [4]


@ewowi, Good news! Your pin configuration is correct

The Seeed XIAO ESP32S3 uses GPIO 5 (SDA) and GPIO 6 (SCL) as the default I2C pins, so your code is using the right pins.

Since the pins are correct but the scanner found 0 devices, the issue is likely one of the following:

Hardware Issues to Check

  1. Physical connections:

    • Double-check all 4 wires (SDA, SCL, VCC, GND) are firmly seated
    • Try different jumper wires (bad wires are common)
  2. MPU6050 module power:

    • Verify the module's power LED is ON
    • Measure voltage at the MPU6050's VCC pin (should be 3.3V)
  3. Pull-up resistors:

    • The XIAO ESP32S3 does NOT have built-in I2C pull-ups
    • Your MPU6050 module MUST have pull-up resistors (most do, but verify)
    • If missing, add external 4.7kΩ resistors from SDA→3.3V and SCL→3.3V
  4. Test the MPU6050 module:

    • Try the module on another board/Arduino if possible
    • The module might be defective

Software Issue: Arduino-ESP32 Core Bug

The ESP_ERR_INVALID_STATE error you're seeing is a known issue with Arduino-ESP32 3.x's new I2C driver. There are reports of i2c_master_receive failing with error 259 (ESP_ERR_INVALID_STATE) in Arduino-ESP32 3.x.

Try adding a small delay after Wire.begin():

bool initI2C() {
  bool success = Wire.begin(5, 6);  // Correct pins for XIAO ESP32S3
  EXT_LOGI(ML_TAG, "initI2C Wire begin %s", success ? "success" : "failure");
  
  if (success) {
    delay(200);  // Longer delay for I2C bus stabilization
    Wire.setClock(100000);  // Explicitly set to 100kHz
  }
  
  return success;
}

What Arduino-ESP32 core version are you using? (Check platform.ini or Arduino IDE board manager)

@ewoudwijma
Copy link

ewoudwijma commented Feb 10, 2026

@coderabbitai,

This is my mcu6050 board , do I need to short wire anything?

image

I am on arduino v3.3.5

@coderabbitai
Copy link

coderabbitai bot commented Feb 10, 2026

Tip

For best results, initiate chat on the files or code changes.

@ewoudwijma, Great photo! I can see your GY-521 MPU6050 board clearly.

What I See in the Image

The Comment image shows a blue GY-521 MPU6050 breakout board with:

  • Green LED is ON - This confirms the module is receiving power correctly
  • Pin headers: VCC, GND, SCL, SDA, XDA, XCL, AD0, INT
  • Surface mount resistors visible (pull-ups are built into this module)
  • Wires connected to the header

The AD0 Pin - Important!

Yes, you should connect the AD0 pin! Here's why:

The AD0 pin controls the I2C address:

  • AD0 = GND (or floating) → Address 0x68 (default)
  • AD0 = VCC → Address 0x69

Right now, your AD0 pin appears to be floating (not connected), which can cause unreliable I2C communication.

Solution: Connect the AD0 pin to GND for the standard 0x68 address.

Arduino-ESP32 3.0.5 I2C Issues

You're on Arduino-ESP32 3.0.5, which has known I2C bugs causing ESP_ERR_INVALID_STATE errors. This is likely contributing to your problem.

Updated Code to Try

Update your initI2C() and onUpdate() methods:

bool initI2C() {
  bool success = Wire.begin(5, 6);  // XIAO ESP32S3 I2C pins
  EXT_LOGI(ML_TAG, "initI2C Wire begin %s", success ? "success" : "failure");
  
  if (success) {
    Wire.setClock(50000);  // Lower clock speed to 50kHz for stability
    delay(200);  // Longer stabilization delay
  }
  
  return success;
}

void onUpdate(const Char<20>& oldValue, const JsonObject& control) {
  EXT_LOGI(ML_TAG, "onUpdate %s", oldValue.charArr);
  if (control["n"].as<Char<20>>() == "on" && nodeState[control["n"].as<Char<20>>()].as<bool>()) {
    if (initI2C()) {
      scanI2C();  // Keep the scanner for now
      
      // Explicitly set MPU6050 address to 0x68
      mpu.setAddr(MPU6050_ADDRESS_AD0_LOW);  // or just use default
      mpu.initialize();
      
      delay(100);
      
      if (mpu.testConnection()) {
        EXT_LOGI(ML_TAG, "MPU6050 connection successful at address 0x68");
        // ... rest of DMP setup
      } else {
        EXT_LOGI(ML_TAG, "MPU6050 connection failed - check AD0 wiring");
      }
    }
  }
}

Wiring Checklist

XIAO ESP32S3 → MPU6050:

  • GPIO 5 → SDA
  • GPIO 6 → SCL
  • 3.3V → VCC
  • GND → GND
  • GND → AD0Add this connection!

Next Steps

  1. Connect AD0 to GND
  2. Lower I2C clock to 50kHz (more stable with ESP32 3.x)
  3. Run the scanner again and let me know if it finds the device at 0x68

If it still doesn't work after grounding AD0, we may need to try Arduino-ESP32 v2.x or use a workaround for the 3.x I2C driver bug.

Let me know what the scanner shows after connecting AD0!

Change MPU6050 driver to IMU driver and add board control (MPU6050 and BMI160)
I2S -< I2C, including safety delays - WIP
add scanI2C
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In `@src/MoonBase/Nodes.h`:
- Around line 347-348: The field Coord3D gravity likely uses integer components
and will truncate the MPU6050 float gravity vector; update the type or handling:
replace Coord3D gravity with a float-based struct (e.g., VectorFloat or a new
Coord3DFloat) or store gravity as an array of floats and ensure any code using
gravity performs the proper float reads/conversions (see related D_IMU.h
handling), or if you must keep Coord3D, add clear documentation and explicit
scaling/quantization conversions where gravity is assigned so values in
[−1.0,1.0] are preserved correctly.

In `@src/MoonLight/Modules/ModuleDrivers.h`:
- Line 109: The build fails because the code calls getNameAndTags<IMUDriver>()
in addControlValue but the actual class in D_IMU.h is defined as MPU6050Driver;
update the template type references to the real class name (replace IMUDriver
with MPU6050Driver) wherever getNameAndTags<IMUDriver>() and related uses appear
(e.g., the addControlValue(control, getNameAndTags<...>()) calls around the IMU
driver registrations), or alternatively rename the class in D_IMU.h to
IMUDriver—ensure consistency between the class declaration (MPU6050Driver) and
all template/type usages (getNameAndTags and addControlValue).

In `@src/MoonLight/Nodes/Drivers/D_IMU.h`:
- Around line 41-56: In initI2C(), fix the comment typos (replace "I2S" with
"I2C"), remove the hardcoded Wire.begin(5, 6) and instead read user-configurable
I2C SDA/SCL pins (use the existing UI control pattern such as
addControl/allocatePin or the pin config variables you have) and pass those pin
values into Wire.begin, and correct the clock mismatch by either setting
Wire.setClock(100000) to match the "100kHz" comment or update the comment to
"50kHz" if 50000 is intended; ensure references to Wire.begin and Wire.setClock
and the initI2C() function are updated accordingly.
- Around line 71-112: onUpdate is performing long blocking initialization (delay
calls, mpu.dmpInitialize, CalibrateAccel, CalibrateGyro) on the HTTP/UI thread
and logging errors with EXT_LOGI; move the heavy work out of onUpdate into a
background task/thread or schedule a non-blocking state-machine so onUpdate only
enqueues/requests initialization and returns immediately, call a new InitIMUTask
(or similar) that runs initI2C(), scanI2C(), mpu.initialize(),
mpu.dmpInitialize(), CalibrateAccel(6), CalibrateGyro(6) and sets
motionTrackingReady when done; also change the two incorrect log calls that
currently use EXT_LOGI for failures (the mpu.dmpInitialize error and the
mpu.testConnection failure) to use EXT_LOGW or EXT_LOGE so errors are logged at
the proper severity.
- Around line 1-20: The class is named MPU6050Driver but other code expects
IMUDriver; rename the class MPU6050Driver to IMUDriver (and rename its
destructor ~MPU6050Driver to ~IMUDriver) so the symbol matches ModuleDrivers.h
references (IMUDriver), and update the file header `@file` from D_MPU6050.h to
D_IMU.h to reflect the real filename; alternatively you may add a type alias
(using IMUDriver = MPU6050Driver) but be sure the destructor/name usages match
across the module.
- Around line 126-153: The gravity VectorFloat components (gravity.x, gravity.y,
gravity.z) are being assigned to sharedData.gravity which is a Coord3D with int
fields, causing truncation; update the code so gravity is stored with proper
precision—either change the SharedData.gravity type from Coord3D (int) to a
float-based struct (e.g., VectorFloat/float fields) or scale the float values
before assignment (e.g., multiply gravity.x/y/z by a factor like 1000 and
convert to int) and document the unit change; locate assignments in the block
handling board == 0 (mpu.dmpGetGravity -> sharedData.gravity.x/y/z) and apply
the chosen fix consistently wherever sharedData.gravity is used.
🧹 Nitpick comments (2)
src/MoonLight/Nodes/Drivers/D_IMU.h (2)

86-86: Local devStatus shadows the private member on Line 162.

Line 86 declares uint8_t devStatus as a local variable, which shadows the private devStatus member (Line 162). The private member is never used elsewhere. Either remove the private member or reuse it here by dropping the local uint8_t declaration.

Proposed fix: remove unused private member
  // MPU control/status vars
-  uint8_t devStatus;       // return status after each device operation (0 = success, !0 = error)
   uint8_t fifoBuffer[64];  // FIFO storage buffer

156-156: Destructor doesn't clean up I2C or DMP state.

When the driver node is removed, the destructor doesn't call mpu.setDMPEnabled(false) or Wire.end(). If the node is destroyed and recreated, this could leave the I2C bus in an inconsistent state — particularly relevant given the I2C initialization issues discussed in the PR comments.

Proposed cleanup
-  ~MPU6050Driver() override {};  // e.g. to free allocated memory
+  ~MPU6050Driver() override {
+    if (motionTrackingReady) {
+      mpu.setDMPEnabled(false);
+    }
+  }

Comment on lines +347 to +348

Coord3D gravity;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Coord3D gravity may not be the right type for gravity data.

As noted in the D_IMU.h review, the MPU6050 gravity vector contains float values in the range [–1.0, 1.0]. If Coord3D uses integer fields (e.g., int16_t), this will silently truncate to 0/±1. Consider using a float-based struct (e.g., VectorFloat) or documenting the expected scale/encoding if you intend to pre-scale the values.

🤖 Prompt for AI Agents
In `@src/MoonBase/Nodes.h` around lines 347 - 348, The field Coord3D gravity
likely uses integer components and will truncate the MPU6050 float gravity
vector; update the type or handling: replace Coord3D gravity with a float-based
struct (e.g., VectorFloat or a new Coord3DFloat) or store gravity as an array of
floats and ensure any code using gravity performs the proper float
reads/conversions (see related D_IMU.h handling), or if you must keep Coord3D,
add clear documentation and explicit scaling/quantization conversions where
gravity is assigned so values in [−1.0,1.0] are preserved correctly.

addControlValue(control, getNameAndTags<ArtNetOutDriver>());
addControlValue(control, getNameAndTags<AudioSyncDriver>());
addControlValue(control, getNameAndTags<IRDriver>());
addControlValue(control, getNameAndTags<IMUDriver>());
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Build failure: IMUDriver is not declared — class is named MPU6050Driver in D_IMU.h.

This is the downstream effect of the class name mismatch in src/MoonLight/Nodes/Drivers/D_IMU.h (Line 16), where the class is defined as MPU6050Driver. The same issue affects Line 147. Either rename the class in D_IMU.h to IMUDriver, or update both references here to MPU6050Driver.

🧰 Tools
🪛 GitHub Actions: PlatformIO CI

[error] 109-109: 'IMUDriver' was not declared in this scope; did you mean 'IRDriver'?


[error] 109-109: no matching function for call to 'getNameAndTags()' (template argument deduction/substitution failed)

🤖 Prompt for AI Agents
In `@src/MoonLight/Modules/ModuleDrivers.h` at line 109, The build fails because
the code calls getNameAndTags<IMUDriver>() in addControlValue but the actual
class in D_IMU.h is defined as MPU6050Driver; update the template type
references to the real class name (replace IMUDriver with MPU6050Driver)
wherever getNameAndTags<IMUDriver>() and related uses appear (e.g., the
addControlValue(control, getNameAndTags<...>()) calls around the IMU driver
registrations), or alternatively rename the class in D_IMU.h to IMUDriver—ensure
consistency between the class declaration (MPU6050Driver) and all template/type
usages (getNameAndTags and addControlValue).

Comment on lines 41 to 56
bool initI2C() {
// tbd: set pins in ui!!
// allocatePin(21, "Pins", "I2S SDA");
// allocatePin(22, "Pins", "I2S SCL");
Wire.end();
delay(10);
bool success = Wire.begin(5, 6);
EXT_LOGI(ML_TAG, "initI2C Wire begin %s", success ? "success" : "failure");

if (success) {
delay(200); // Give I2C bus time to stabilize
Wire.setClock(50000); // Explicitly set to 100kHz
}

return success;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hardcoded I2C pins and comment/code mismatch on clock speed.

Three items here:

  1. Lines 43–44: Comments reference "I2S" — should be "I2C".
  2. Line 47: Wire.begin(5, 6) hardcodes GPIO5/GPIO6 (XIAO ESP32S3). This will break on other boards. Consider making the pins configurable via UI controls (the commented-out addControl approach on lines 42–44 is the right direction).
  3. Line 52: Comment says "Explicitly set to 100kHz" but the code sets 50000 (50 kHz).
Fix the comment typos
-    // tbd: set pins in ui!!
-    // allocatePin(21, "Pins", "I2S SDA");
-    // allocatePin(22, "Pins", "I2S SCL");
+    // tbd: set pins in ui!!
+    // allocatePin(21, "Pins", "I2C SDA");
+    // allocatePin(22, "Pins", "I2C SCL");
-      Wire.setClock(50000);  // Explicitly set to 100kHz
+      Wire.setClock(50000);  // Explicitly set to 50kHz (lower for reliability)
🤖 Prompt for AI Agents
In `@src/MoonLight/Nodes/Drivers/D_IMU.h` around lines 41 - 56, In initI2C(), fix
the comment typos (replace "I2S" with "I2C"), remove the hardcoded Wire.begin(5,
6) and instead read user-configurable I2C SDA/SCL pins (use the existing UI
control pattern such as addControl/allocatePin or the pin config variables you
have) and pass those pin values into Wire.begin, and correct the clock mismatch
by either setting Wire.setClock(100000) to match the "100kHz" comment or update
the comment to "50kHz" if 50000 is intended; ensure references to Wire.begin and
Wire.setClock and the initI2C() function are updated accordingly.

ModuleIO
- Include Wire
- Add controls i2cFreq and i2cBus
- default assign sda to pin 21 and scl to pin 22
- readPins: add I2C pin assignment
- add updateDevices: Scanning I2C bus and send frequency and addresses to UI

IMU driver
- remove initI2C and scan I2C
- gravity xyz float to int correction - WIP
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@src/MoonBase/Modules/ModuleIO.h`:
- Line 222: The i2cFreq control created via addControl(controls, "i2cFreq", ...)
uses a max of 65534 which is below typical Wire.getClock() values (100000,
400000); update the max bound on the "i2cFreq" control in ModuleIO.h to a larger
value (e.g., 1000000 or UINT32_MAX) so real I2C clock frequencies aren't
clamped, ensuring the chosen max fits the addControl parameter type (int vs.
unsigned) and adjusting the type if necessary.
- Around line 928-929: Remove the sentinel fake I2C device being appended with
address 255: stop creating the JsonObject via
newState["i2cBus"].as<JsonArray>().add<JsonObject>() and assigning
i2cDevice["address"] = 255 so the i2cBus array only contains real scan results;
if a non-empty-array indicator is needed, handle that in the UI layer instead of
adding a sentinel entry (look for usages of i2cDevice/newState["i2cBus"] in
ModuleIO.h and related scan/serialize functions to ensure no other code depends
on the sentinel).
- Around line 819-826: readPins() can call Wire.begin() repeatedly without
cleanup causing "Bus already started in Master Mode" on ESP32; before calling
Wire.begin(pinI2CSDA, pinI2CSCL) ensure you call Wire.end() if Wire is already
initialized (check Wire.isEnabled() or track a local/field flag), then call
Wire.begin(...) and log via EXT_LOGI/EXT_LOGE as before; also re-enable the
stabilization delay and explicit clock set (uncomment the delay(200) and
Wire.setClock(...) lines and set the clock to the recommended value for
Arduino-ESP32 3.x) so initI2C sequence (pins pinI2CSDA/pinI2CSCL, Wire.begin/
Wire.end, delay, Wire.setClock) is safe when readPins() runs repeatedly.
- Around line 905-908: Loop1s() is calling updateDevices() every second which
triggers a full I2C bus scan and causes blocking/traffic; change this so
updateDevices() is not invoked from loop1s(); instead call updateDevices() once
after a successful Wire.begin() (e.g., in readPins() after initializing I2C when
pinI2CSCL and pinI2CSDA are valid) and cache the discovered devices, and provide
an explicit on-demand way to rescan if needed; update references to pinI2CSCL
and pinI2CSDA checks so the scan only happens once after initialization and
remove the per-second call in loop1s().
🧹 Nitpick comments (4)
src/MoonLight/Nodes/Drivers/D_IMU.h (4)

22-27: gravityVector (Line 26) is declared but never used.

The public VectorFloat gravityVector member is never read or written in this file. The private gravity member (Line 139) is what's actually used for DMP calculations. Either remove gravityVector or wire it up to serve its intended purpose.


55-55: Local devStatus on Line 55 shadows the private member on Line 131.

The uint8_t devStatus declared inside onUpdate shadows the class member of the same name, leaving the member uninitialized and unused. Remove the member declaration on Line 131 since the variable is only needed locally.

Proposed fix
  // MPU control/status vars
-  uint8_t devStatus;       // return status after each device operation (0 = success, !0 = error)
   uint8_t fifoBuffer[64];  // FIFO storage buffer

Also applies to: 131-131


100-105: gyro values also suffer float→int truncation (fractional degrees lost).

ypr[i] * 180 / M_PI produces floats (e.g., 45.73°) that truncate to integers when assigned to Coord3D (int fields). This loses sub-degree resolution. The gravity scaling via INT16_MAX (Lines 103–105) addresses the equivalent issue for gravity — consider a similar scaling approach for gyro, or document that integer-degree precision is intentional.


96-121: Redundant re-parsing of quaternion, accel, and gravity (Lines 111–113) with only a comment as justification.

The comment "needed to repeat the following 3 lines (yes if you look at the output: otherwise not 0)" suggests an empirical workaround. The DMP packet is deterministic — re-parsing the same fifoBuffer should yield the same results. This likely masks an issue with FIFO alignment or a stale-buffer read. Consider investigating the root cause rather than doubling the parsing work every 20ms cycle.

addControl(rows, "DriveCap", "text", 0, 32, true); // ro
}

addControl(controls, "i2cFreq", "number", 0, 65534, true);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

i2cFreq max value is too low for actual I2C clock frequencies.

Wire.getClock() returns values like 100000 (100 kHz) or 400000 (400 kHz), but the control max is 65534. Depending on how the UI enforces this bound, the displayed value will be clamped/truncated or cause unexpected behavior.

Proposed fix
-    addControl(controls, "i2cFreq", "number", 0, 65534, true);
+    addControl(controls, "i2cFreq", "number", 0, 400000, true);

Note: If addControl max parameter is limited to int range this should still work. If it needs to accommodate higher values (e.g., 1 MHz fast-mode plus), adjust accordingly.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
addControl(controls, "i2cFreq", "number", 0, 65534, true);
addControl(controls, "i2cFreq", "number", 0, 400000, true);
🤖 Prompt for AI Agents
In `@src/MoonBase/Modules/ModuleIO.h` at line 222, The i2cFreq control created via
addControl(controls, "i2cFreq", ...) uses a max of 65534 which is below typical
Wire.getClock() values (100000, 400000); update the max bound on the "i2cFreq"
control in ModuleIO.h to a larger value (e.g., 1000000 or UINT32_MAX) so real
I2C clock frequencies aren't clamped, ensuring the chosen max fits the
addControl parameter type (int vs. unsigned) and adjusting the type if
necessary.

Comment on lines +928 to +929
JsonObject i2cDevice = newState["i2cBus"].as<JsonArray>().add<JsonObject>();
i2cDevice["address"] = 255;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Sentinel entry with address 255 pollutes the device list.

A fake device at address 255 is appended after the real scan results. This is not a valid I2C address (7-bit range is 0–127) and will appear as a spurious device in the UI. If this is meant to ensure the i2cBus array is non-empty or signal end-of-list, consider handling that in the UI layer instead.

Proposed fix: remove the sentinel
     EXT_LOGI(ML_TAG, "Found %d device(s)", count);
-    JsonObject i2cDevice = newState["i2cBus"].as<JsonArray>().add<JsonObject>();
-    i2cDevice["address"] = 255;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
JsonObject i2cDevice = newState["i2cBus"].as<JsonArray>().add<JsonObject>();
i2cDevice["address"] = 255;
// Lines 928-929 removed - sentinel entry deleted
🤖 Prompt for AI Agents
In `@src/MoonBase/Modules/ModuleIO.h` around lines 928 - 929, Remove the sentinel
fake I2C device being appended with address 255: stop creating the JsonObject
via newState["i2cBus"].as<JsonArray>().add<JsonObject>() and assigning
i2cDevice["address"] = 255 so the i2cBus array only contains real scan results;
if a non-empty-array indicator is needed, handle that in the UI layer instead of
adding a sentinel entry (look for usages of i2cDevice/newState["i2cBus"] in
ModuleIO.h and related scan/serialize functions to ensure no other code depends
on the sentinel).

Module IO
- setup, onUpdate: i2cFreq is modifyable (in kHz)
- setBoardPresetDefaults: i2c default 21,22 on esp32d0 and 8, 9 on others - wip
- readPins: reinit Wire after i2c pins changed, using freq, and updateDevices(not every s)

IMU module
- remove gravityVector
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@src/MoonBase/Modules/ModuleIO.h`:
- Around line 813-836: Reset pinI2CSDA and pinI2CSCL to UINT8_MAX before
scanning the _state.data["pins"] array so stale GPIO values don't persist when a
preset has no I2C pins; in the function/block that iterates over pins (the loop
referencing pinObject["usage"] and setting pinI2CSDA/pinI2CSCL), explicitly set
pinI2CSDA = UINT8_MAX and pinI2CSCL = UINT8_MAX immediately prior to the for
loop, then keep the existing conditional Wire.begin(...) logic that checks for
UINT8_MAX.

In `@src/MoonLight/Nodes/Drivers/D_IMU.h`:
- Around line 40-80: Replace the hardcoded i2cInited = true in onUpdate with a
real I2C readiness check: query the ModuleIO state (e.g. moduleIO->pinI2CSDA
and/or moduleIO->pinI2CSCL != UINT8_MAX or a Wire.begin() success flag) to
determine if I2C is initialized before proceeding with MPU6050 initialization;
update the i2cInited variable accordingly and short-circuit the MPU setup (and
log a warning) when the check fails (references: onUpdate, i2cInited,
moduleIO->pinI2CSDA, Wire.begin).
- Around line 90-117: The code calls mpu.dmpGetLinearAccel(&aaReal, &aa,
&gravity) using aa which is never populated; restore or add a call to
mpu.dmpGetAccel(&aa, fifoBuffer) after obtaining the FIFO packet/quaternion
(e.g., after mpu.dmpGetQuaternion(&q, fifoBuffer)) so aa is filled before
dmpGetLinearAccel, and remove/adjust the commented duplicate lines accordingly
to ensure accell.x/y/z come from a valid aaReal.
🧹 Nitpick comments (1)
src/MoonBase/Modules/ModuleIO.h (1)

839-840: pinI2CSDA / pinI2CSCL are in the public section — consider moving to private.

These members are declared between the public readPins() / updateDevices() methods and the explicit private: label on line 943. If external access from the IMU driver is needed (e.g., to verify pin config), keep them public intentionally; otherwise move them below line 943.

@ewowi
Copy link
Collaborator Author

ewowi commented Feb 11, 2026

@coderabbitai,
can you do a thorough review of D_IMU.h and ModuleIO.h based on the changes in this PR. Especially focus on the decision to move the Wire code into ModuleIO.h making it a generic solution for all I2C devices. Is this the right approach. Also include a recommendation for default SDA/SCL pins for all supported ESP32 MCU's. Include follow up recommendations, including:

  • The i2cBus control in ModuleIO.h now only shows an address, can we also add the name of the device? Should the IMU driver node tell that to the IO module? How do we know which address belongs to which module? Can the IMU module query for this in the IMU board
  • Als add a proposal how to add support for the BMI160 IMU. We now use ElectronicCats/MPU6050 (see platformio.ini) for MPU650 support, do we need another library for the BMI160 - and for each future supported IMU? would be nice if there is one generic library for all IMU boards

Also make a recommendation how to do the same with SPI (generic in Module IO, other modules can use it).

@coderabbitai
Copy link

coderabbitai bot commented Feb 11, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

echo "=== Reading D_IMU.h ==="
cat src/MoonLight/Nodes/Drivers/D_IMU.h

echo -e "\n\n=== Reading ModuleIO.h ==="
cat src/MoonBase/Modules/ModuleIO.h

echo -e "\n\n=== Reading platformio.ini to see library dependencies ==="
cat platformio.ini | grep -A 20 "lib_deps"

echo -e "\n\n=== Checking for other I2C device drivers ==="
fd -e h "D_.*\.h" src/MoonLight/Nodes/Drivers/ -x echo "File: {}" \; -x head -30 {}

echo -e "\n\n=== Looking for ESP32 board definitions and pin configurations ==="
rg -n "ESP32|ARDUINO_" platformio.ini

echo -e "\n\n=== Searching for existing SPI usage in the codebase ==="
rg -n "SPI\.|#include.*SPI\.h" --type cpp --type-add 'cpp:*.{h,cpp}' -C 2

Length of output: 61352


🌐 Web query:

ESP32 ESP32-S2 ESP32-S3 ESP32-C3 ESP32-C6 ESP32-P4 default I2C pins GPIO

💡 Result:

On ESP32-family SoCs there aren’t “fixed” I2C pins in hardware—the I2C signals are routed through the GPIO matrix, so you can map SDA/SCL to many usable GPIOs (subject to each pin’s constraints). [7]

What people call “default I2C pins” usually means the defaults chosen by a software platform/board variant (most commonly Arduino-ESP32 Wire.begin() with no pin arguments).

Arduino-ESP32 (Wire) default SDA/SCL by generic SoC variant

SoC Default SDA Default SCL Notes / source
ESP32 GPIO21 GPIO22 Arduino-ESP32 docs (Generic ESP32). [1]
ESP32-S2 GPIO8 GPIO9 Mentioned as coming from pins_arduino.h for ESP32-S2. [2]
ESP32-S3 GPIO8 GPIO9 Shown from variants/esp32s3/pins_arduino.h. [3]
ESP32-C3 GPIO8 GPIO9 Stated as default for “ESP32C3 Dev Module” in Arduino-ESP32. [4]
ESP32-C6 GPIO23 GPIO22 Shown as variants/esp32c6/pins_arduino.h defaults; also notes LP I2C pins SDA1=GPIO6, SCL1=GPIO7. [5]
ESP32-P4 (no single universal default) (no single universal default) Boards commonly pick something like SDA=GPIO7 / SCL=GPIO8 (example ESP32-P4 boards/docs). [6]

Reminder (all chips above)

If you don’t want the defaults, set pins explicitly (Arduino: Wire.begin(sda, scl) / Wire.setPins(sda, scl); ESP-IDF: configure sda_io_num / scl_io_num). ESP-IDF’s I2C setup is explicitly pin-config driven. [8]


🌐 Web query:

BMI160 Arduino ESP32 library IMU accelerometer gyroscope

💡 Result:

Good Arduino/ESP32 libraries for the Bosch BMI160 (accel + gyro)

  1. hanyazou / BMI160-Arduino (API header: BMI160Gen.h)
  • Supports SPI and I2C; example shows BMI160.begin(...SPI_MODE...) and ...I2C_MODE, 0x68) and basic readGyro() usage. [1]
  • I2C note: tie SDO/SA0 to GND for the default address (example uses 0x68). [1]
  1. DFRobot_BMI160 (easy I2C-focused library; includes step counter helpers)
  • Provides I2cInit() and getSensorData() methods; documents 0x68 when SDIO is GND and 0x69 as the alternate address. [2]
  • README lists FireBeetle-ESP32 as “Work Well” (so it’s known to run on ESP32-class boards). [2]
  1. EmotiBit_BMI160 (Arduino Library Manager listing exists)
  • Published as “EmotiBit BMI160”; supports BMI160 (+ BMM150) and is based on the CurieIMU-style API. [3]

Minimal wiring (I2C, typical ESP32)

  • VCC → 3.3V, GND → GND, SDA/SCL → ESP32 SDA/SCL
  • Set address with SDO/SA0: low → 0x68, high → 0x69 (per DFRobot docs). [2]

Minimal code (hanyazou / BMI160Gen.h, I2C)

`#include` <Wire.h>
`#include` <BMI160Gen.h>

const int i2c_addr = 0x68;

void setup() {
  Serial.begin(115200);
  Wire.begin(); // (On ESP32 you can also pass SDA,SCL pins if needed)
  BMI160.begin(BMI160GenClass::I2C_MODE, i2c_addr);  // shown in repo example [1]
}

void loop() {
  int gx, gy, gz;
  BMI160.readGyro(gx, gy, gz); // shown in repo example [1]
  Serial.printf("g: %d %d %d\n", gx, gy, gz);
  delay(200);
}

Sources: [1] [2] [3]


🌐 Web query:

Arduino unified IMU library MPU6050 BMI160 LSM6DS3 multi-sensor support

💡 Result:

  • Closest thing to a “unified” Arduino IMU API: Adafruit’s Unified Sensor abstraction (Adafruit_Sensor) which standardizes reads into sensors_event_t across many Adafruit drivers. (arduinolibraries.info)
  • MPU6050: Adafruit_MPU6050 uses Adafruit’s stack (BusIO + Unified Sensor) and exposes accel/gyro (and temp). (learn.adafruit.com)
  • LSM6DS3 family: Adafruit’s Adafruit_LSM6DS library supports multiple LSM6DS variants (including LSM6DS33 and others) and also uses Unified Sensor. (learn.adafruit.com)
  • BMI160: There isn’t a commonly-used Arduino library for BMI160 that plugs into Adafruit Unified Sensor the same way; typical options are vendor/community drivers like DFRobot_BMI160 or EmotiBit_BMI160, with their own APIs. (arduinolibraries.info)

Practical multi-sensor approach (MPU6050 + LSM6DS3 + BMI160):

  1. Use Adafruit Unified Sensor for MPU6050 + LSM6DS3*, so your code reads both via a similar getEvent() pattern. (learn.adafruit.com)
  2. Wrap your BMI160 driver in a small adapter that outputs the same fields (accel/gyro/temp) in a struct compatible with your unified path (or mimic Adafruit’s sensors_event_t). (arduinolibraries.info)

If you tell me which BMI160 breakout you’re using (DFRobot? Bosch? other) and whether you need I2C or SPI, I can point you to the best-maintained driver + sketch an adapter interface.

Citations:


@ewowi, I've completed a thorough analysis of the I2C centralization approach, researched ESP32 pin defaults, and explored BMI160 and generic IMU library options. Here's my comprehensive review:

Architectural Review: I2C Centralization in ModuleIO.h

Is the centralized approach correct?

Yes, this is an excellent architectural decision! The centralization of I2C in ModuleIO.h provides:

  1. Single Source of Truth: One place manages Wire initialization, pin configuration, and bus state
  2. Prevents conflicts: Avoids multiple drivers calling Wire.begin() with different pins
  3. Runtime reconfiguration: Pin changes trigger automatic Wire reinitialization
  4. Bus discovery: Centralized updateDevices() scans all I2C devices once
  5. Frequency control: Single i2cFreq parameter affects all I2C devices

Current Implementation Quality

ModuleIO.h I2C Management:

  • ✅ Pin reading from UI configuration (pin_I2C_SDA, pin_I2C_SCL)
  • ✅ Wire.end() before reinit to clean up previous state
  • ✅ Frequency configuration with Wire.setClock()
  • ✅ Device scanning with updateDevices()
  • ⚠️ Issue: i2cBus only shows addresses, not device names

D_IMU.h Integration:

  • ✅ Removed I2C initialization logic (no longer calls Wire.begin)
  • ✅ Relies on Module IO for bus setup
  • ⚠️ Issue: bool i2cInited = true; // todo: check in moduleIO if successfull - hardcoded, should actually check ModuleIO state

📌 Recommendation 1: Default I2C Pins for All ESP32 Variants

Based on Arduino-ESP32 platform defaults, update the default pin configuration in ModuleIO.h:

// In setBoardPresetDefaults() for board_none (default case):
`#ifdef` CONFIG_IDF_TARGET_ESP32
  pinAssigner.assignPin(21, pin_I2C_SDA);   // ESP32 classic
  pinAssigner.assignPin(22, pin_I2C_SCL);
`#elif` defined(CONFIG_IDF_TARGET_ESP32S2)
  pinAssigner.assignPin(8, pin_I2C_SDA);    // ESP32-S2
  pinAssigner.assignPin(9, pin_I2C_SCL);
`#elif` defined(CONFIG_IDF_TARGET_ESP32S3)
  pinAssigner.assignPin(8, pin_I2C_SDA);    // ESP32-S3
  pinAssigner.assignPin(9, pin_I2C_SCL);
`#elif` defined(CONFIG_IDF_TARGET_ESP32C3)
  pinAssigner.assignPin(8, pin_I2C_SDA);    // ESP32-C3
  pinAssigner.assignPin(9, pin_I2C_SCL);
`#elif` defined(CONFIG_IDF_TARGET_ESP32C6)
  pinAssigner.assignPin(23, pin_I2C_SDA);   // ESP32-C6
  pinAssigner.assignPin(22, pin_I2C_SCL);
`#elif` defined(CONFIG_IDF_TARGET_ESP32P4)
  pinAssigner.assignPin(7, pin_I2C_SDA);    // ESP32-P4 (common board default)
  pinAssigner.assignPin(8, pin_I2C_SCL);
`#else`
  pinAssigner.assignPin(21, pin_I2C_SDA);   // Fallback
  pinAssigner.assignPin(22, pin_I2C_SCL);
`#endif`

Note: Your current code already handles ESP32 vs others, but could be more explicit for C6 and P4.


📌 Recommendation 2: Device Naming Strategy for i2cBus

Problem: i2cBus control only shows numeric addresses (e.g., 0x68), not device names.

Solution: Implement a device identification registry

Step 1: Add device name field to i2cBus control

In ModuleIO.h:

control = addControl(controls, "i2cBus", "rows");
control["crud"] = "r";
rows = control["n"].to<JsonArray>();
{
  addControl(rows, "address", "number", 0, 255, true);  // ro
  addControl(rows, "name", "text", 0, 32, true);        // ro - NEW
}

Step 2: Create I2C device registry in ModuleIO

// In ModuleIO.h private section:
struct I2CDeviceInfo {
  uint8_t address;
  const char* name;
  Node* owner;  // Which node claimed this device
};
std::vector<I2CDeviceInfo> i2cDeviceRegistry;

public:
// Allow drivers to register their I2C devices
void registerI2CDevice(uint8_t address, const char* name, Node* owner) {
  for (auto& dev : i2cDeviceRegistry) {
    if (dev.address == address) {
      dev.name = name;
      dev.owner = owner;
      return;
    }
  }
  i2cDeviceRegistry.push_back({address, name, owner});
}

Step 3: Update updateDevices() to use registry

void updateDevices() {
  JsonDocument doc;
  doc["i2cBus"].to<JsonArray>();
  JsonObject newState = doc.as<JsonObject>();

  EXT_LOGI(ML_TAG, "Scanning I2C bus...");
  byte count = 0;
  for (byte i = 1; i < 127; i++) {
    Wire.beginTransmission(i);
    if (Wire.endTransmission() == 0) {
      JsonObject i2cDevice = newState["i2cBus"].as<JsonArray>().add<JsonObject>();
      i2cDevice["address"] = i;
      
      // Look up device name in registry
      const char* deviceName = "Unknown";
      for (const auto& dev : i2cDeviceRegistry) {
        if (dev.address == i) {
          deviceName = dev.name;
          break;
        }
      }
      i2cDevice["name"] = deviceName;
      
      EXT_LOGI(ML_TAG, "Found I2C device at address 0x%02X: %s", i, deviceName);
      count++;
    }
  }
  EXT_LOGI(ML_TAG, "Found %d device(s)", count);
  
  doc["i2cFreq"] = Wire.getClock() / 1000;
  update(newState, ModuleState::update, _moduleName);
}

Step 4: IMU driver registers itself

In D_IMU.h onUpdate():

if (control["on"] == true) {
  bool i2cInited = true;  // TODO: get from ModuleIO
  if (i2cInited) {
    if (board == 0) {  // MPU6050
      // Register this device with Module IO
      ModuleIO* io = modules->getModuleOfClass<ModuleIO>();
      if (io) {
        io->registerI2CDevice(0x68, "MPU6050", this);  // or 0x69 if AD0 high
      }
      
      mpu.initialize();
      // ... rest of code
    }
  }
}

Alternative Simpler Approach: Known I2C address database in ModuleIO:

const char* getDeviceNameByAddress(uint8_t addr) {
  switch(addr) {
    case 0x68: return "MPU6050/MPU9250";  // Could be either
    case 0x69: return "MPU6050/MPU9250 (AD0=HIGH)";
    case 0x76: return "BME280/BMP280";
    case 0x77: return "BME280/BMP280 (ALT)";
    case 0x3C: return "OLED SSD1306";
    case 0x3D: return "OLED SSD1306 (ALT)";
    case 0x40: return "PCA9685/INA219";
    // ... add more as needed
    default: return "Unknown";
  }
}

This is simpler but less accurate (address collisions between different chips). I recommend the registry approach for accuracy.


📌 Recommendation 3: BMI160 IMU Support

Library Recommendation

Use hanyazou/BMI160-Arduino (BMI160Gen.h API) - supports both I2C and SPI with simple API.

Why this library:

  • ✅ I2C and SPI support
  • ✅ I2C addresses: 0x68 (SDO=GND) or 0x69 (SDO=VCC)
  • ✅ Similar API structure to MPU6050 libraries
  • ✅ Actively maintained

Add to platformio.ini:

lib_deps = 
  ElectronicCats/MPU6050 @ 1.3.0 ; for MPU6050
  hanyazou/BMI160-Arduino @ ^1.0.0 ; for BMI160

Implementation in D_IMU.h

`#if` FT_MOONLIGHT

`#include` <MPU6050_6Axis_MotionApps20.h>
`#include` <BMI160Gen.h>  // NEW

class IMUDriver : public Node {
  // ... existing code ...
  
  void onUpdate(const Char<20>& oldValue, const JsonObject& control) override {
    if (!control["on"].isNull()) {
      if (control["on"] == true) {
        bool i2cInited = true;  // TODO: check ModuleIO
        if (i2cInited) {
          if (board == 0) {  // MPU6050
            // Register with Module IO
            ModuleIO* io = modules->getModuleOfClass<ModuleIO>();
            if (io) io->registerI2CDevice(0x68, "MPU6050", this);
            
            mpu.initialize();
            // ... existing MPU6050 code ...
            
          } else if (board == 1) {  // BMI160 - NEW
            // Register with Module IO
            ModuleIO* io = modules->getModuleOfClass<ModuleIO>();
            if (io) io->registerI2CDevice(0x68, "BMI160", this);
            
            BMI160.begin(BMI160GenClass::I2C_MODE, 0x68);
            
            if (BMI160.getDeviceID() == 0xD1) {  // BMI160 device ID
              EXT_LOGI(ML_TAG, "BMI160 connection successful");
              motionTrackingReady = true;
            } else {
              EXT_LOGW(ML_TAG, "BMI160 connection failed");
            }
          }
        }
      }
    }
  }
  
  void loop20ms() override {
    if (!motionTrackingReady) return;
    
    if (board == 0) {  // MPU6050
      // ... existing MPU6050 loop code ...
      
    } else if (board == 1) {  // BMI160 - NEW
      int gx, gy, gz, ax, ay, az;
      BMI160.readGyro(gx, gy, gz);
      BMI160.readAccelerometer(ax, ay, az);
      
      // Convert raw values to degrees (BMI160 gyro: 16.4 LSB/°/s at ±2000°/s range)
      gyro.x = gx / 16.4f;
      gyro.y = gy / 16.4f;
      gyro.z = gz / 16.4f;
      
      // Convert raw accel values (BMI160 accel: 16384 LSB/g at ±2g range)
      accell.x = ax;
      accell.y = ay;
      accell.z = az;
      
      // Calculate gravity vector from accelerometer
      float norm = sqrt(ax*ax + ay*ay + az*az);
      if (norm > 0) {
        sharedData.gravity.x = (ax / norm) * INT16_MAX;
        sharedData.gravity.y = (ay / norm) * INT16_MAX;
        sharedData.gravity.z = (az / norm) * INT16_MAX;
      }
    }
  }
  
 private:
  MPU6050 mpu;
  // BMI160 is accessed via static BMI160 object from library
};

`#endif`

Note: BMI160 uses I2C address 0x68 when SDO is grounded, same as MPU6050, so they can't coexist on the same bus without address changes.


📌 Recommendation 4: Generic IMU Library Strategy

Reality Check

Adafruit Unified Sensor is the closest to a unified Arduino IMU API, but BMI160 doesn't have Adafruit Unified Sensor support.

Recommended Approach: Internal Abstraction Layer

Don't try to use a single external library - instead, create your own thin abstraction:

// In D_IMU.h - add this interface
struct IMUSensorData {
  Coord3D gyro;       // degrees/sec
  Coord3D accell;     // raw or m/s²
  Coord3D gravity;    // normalized
  bool valid;
};

class IMUDriver : public Node {
  // ... existing code ...
  
private:
  // Unified read method
  IMUSensorData readSensor() {
    IMUSensorData data = {};
    
    if (board == 0) {  // MPU6050
      if (mpu.dmpGetCurrentFIFOPacket(fifoBuffer)) {
        mpu.dmpGetQuaternion(&q, fifoBuffer);
        mpu.dmpGetGravity(&gravity, &q);
        mpu.dmpGetYawPitchRoll(ypr, &q, &gravity);
        
        data.gyro.x = ypr[1] * 180 / M_PI;
        data.gyro.y = ypr[0] * 180 / M_PI;
        data.gyro.z = ypr[2] * 180 / M_PI;
        
        mpu.dmpGetLinearAccel(&aaReal, &aa, &gravity);
        data.accell.x = aaReal.x;
        data.accell.y = aaReal.y;
        data.accell.z = aaReal.z;
        
        data.gravity.x = gravity.x * INT16_MAX;
        data.gravity.y = gravity.y * INT16_MAX;
        data.gravity.z = gravity.z * INT16_MAX;
        data.valid = true;
      }
      
    } else if (board == 1) {  // BMI160
      int gx, gy, gz, ax, ay, az;
      BMI160.readGyro(gx, gy, gz);
      BMI160.readAccelerometer(ax, ay, az);
      
      data.gyro.x = gx / 16.4f;
      data.gyro.y = gy / 16.4f;
      data.gyro.z = gz / 16.4f;
      
      data.accell.x = ax;
      data.accell.y = ay;
      data.accell.z = az;
      
      float norm = sqrt(ax*ax + ay*ay + az*az);
      if (norm > 0) {
        data.gravity.x = (ax / norm) * INT16_MAX;
        data.gravity.y = (ay / norm) * INT16_MAX;
        data.gravity.z = (az / norm) * INT16_MAX;
      }
      data.valid = true;
    }
    
    return data;
  }
  
public:
  void loop20ms() override {
    if (!motionTrackingReady) return;
    
    IMUSensorData data = readSensor();
    if (data.valid) {
      gyro = data.gyro;
      accell = data.accell;
      sharedData.gravity = data.gravity;
      EXT_LOGD(ML_TAG, "Gyro: %f %f %f", gyro.x, gyro.y, gyro.z);
    }
  }
};

Benefits:

  • ✅ Single loop20ms() code path
  • ✅ Easy to add new IMU chips (just add another else if in readSensor())
  • ✅ No dependency on unified external library
  • ✅ Keep using best-in-class chip-specific libraries

Future IMUs to add:

  • LSM6DS3 / LSM6DSO (common on newer boards)
  • ICM-20948 (9-axis with magnetometer)
  • BNO055/BNO085 (fusion-ready IMUs)

📌 Recommendation 5: SPI Centralization (Similar to I2C)

Current State

  • ✅ SPI pin types defined in ModuleIO (pin_SPI_SCK, pin_SPI_MISO, pin_SPI_MOSI)
  • ⚠️ SPI.begin() called in EthernetSettingsService.cpp (not centralized)

Proposed Changes

1. Add SPI management to ModuleIO.h:

// In setupDefinition():
addControl(controls, "spiFreq", "number", 0, 80000, false, "kHz");

control = addControl(controls, "spiBus", "rows");
control["crud"] = "r";
rows = control["n"].to<JsonArray>();
{
  addControl(rows, "cs", "number", 0, SOC_GPIO_PIN_COUNT-1, true);  // ro
  addControl(rows, "name", "text", 0, 32, true);                    // ro
}

// Private members:
uint8_t pinSPISCK = UINT8_MAX;
uint8_t pinSPIMISO = UINT8_MAX;
uint8_t pinSPIMOSI = UINT8_MAX;
std::vector<SPIDeviceInfo> spiDeviceRegistry;

struct SPIDeviceInfo {
  uint8_t csPin;
  const char* name;
  Node* owner;
};

// Public methods:
void registerSPIDevice(uint8_t csPin, const char* name, Node* owner);
bool isSPIInitialized() const { return pinSPISCK != UINT8_MAX && pinSPIMISO != UINT8_MAX && pinSPIMOSI != UINT8_MAX; }

2. Initialize SPI in readPins():

void readPins() {
  // ... existing I2C code ...
  
  // SPI pin detection
  for (JsonObject pinObject : _state.data["pins"].as<JsonArray>()) {
    uint8_t usage = pinObject["usage"];
    if (usage == pin_SPI_SCK) {
      pinSPISCK = pinObject["GPIO"];
      EXT_LOGD(ML_TAG, "SPISCK found %d", pinSPISCK);
    }
    if (usage == pin_SPI_MISO) {
      pinSPIMISO = pinObject["GPIO"];
      EXT_LOGD(ML_TAG, "SPIMISO found %d", pinSPIMISO);
    }
    if (usage == pin_SPI_MOSI) {
      pinSPIMOSI = pinObject["GPIO"];
      EXT_LOGD(ML_TAG, "SPIMOSI found %d", pinSPIMOSI);
    }
  }
  
  if (pinSPISCK != UINT8_MAX && pinSPIMISO != UINT8_MAX && pinSPIMOSI != UINT8_MAX) {
    SPI.end();  // Clean up any previous SPI initialization
    SPI.begin(pinSPISCK, pinSPIMISO, pinSPIMOSI);
    uint16_t frequency = _state.data["spiFreq"];
    if (frequency > 0) {
      // SPI frequency is set per-transaction, not globally like I2C
      EXT_LOGI(ML_TAG, "SPI bus initialized: sck:%d miso:%d mosi:%d", pinSPISCK, pinSPIMISO, pinSPIMOSI);
    }
  }
}

3. Update EthernetSettingsService to use ModuleIO's SPI:

// In EthernetSettingsService.cpp, replace SPI.begin() with:
ModuleIO* io = /* get ModuleIO reference */;
if (io && io->isSPIInitialized()) {
  // SPI already initialized by ModuleIO, just use it
  ETH.begin(v_ETH_PHY_TYPE, v_ETH_PHY_ADDR, v_ETH_PHY_CS, v_ETH_PHY_IRQ, v_ETH_PHY_RST, SPI);
} else {
  EXT_LOGW("Ethernet", "SPI not initialized by ModuleIO");
}

Benefits:

  • ✅ Consistent architecture (I2C and SPI both in ModuleIO)
  • ✅ Single source of truth for bus configuration
  • ✅ Easy to add SPI-based drivers (displays, SD cards, etc.)
  • ✅ Prevents pin conflicts

📋 Summary of Follow-up Actions

High Priority:

  1. Fix i2cInited check in D_IMU.h - currently hardcoded to true
  2. Add device naming to i2cBus - implement registry approach
  3. Update ESP32-C6 and ESP32-P4 default pins - explicit per-variant defaults
  4. Add BMI160 support - use hanyazou/BMI160-Arduino library

Medium Priority:

  1. Create internal IMU abstraction - unified readSensor() method
  2. Centralize SPI in ModuleIO - mirror I2C architecture
  3. Document I2C/SPI device registration - for future driver developers

Low Priority:

  1. Add more IMU chips (LSM6DS3, ICM-20948, BNO055)
  2. Add SPI device scanning (enumerate CS pins with known devices)
  3. Add I2C pull-up resistor strength configuration

Would you like me to generate code for any specific recommendation, or shall I create a GitHub issue with this analysis as a follow-up task?


🧠 Learnings used
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-01-26T14:59:22.091Z
Learning: In MoonModules/MoonLight PR `#114`: ModuleDevices deadlock caused by: (1) StatefulService.h line 204-205 bug - after 500ms mutex timeout, continues WITHOUT acquiring mutex, causing race conditions and JSON corruption. (2) receiveUDP() while-loop processes own UDP broadcasts via loopback, creating circular call chain: sendUDP→broadcast→receiveUDP→updateDevices→update→handlers→sendUDP. (3) updateDevices() holds _accessMutex during expensive sort/JSON operations with 15 devices, can exceed 500ms with nested calls. Solution: Fix StatefulService to wait forever or reboot on timeout; skip own broadcasts by checking message.name == ownHostname; optimize updateDevices to sort outside mutex.

@ewowi
Copy link
Collaborator Author

ewowi commented Feb 11, 2026

@coderabbitai, I also want to add my-sr602 pir and digital microphones like the ics43432/4 or inmp441

How to add these, generic solution like for i2c and spi needed ?

And do i2s microphones interfere with i2s FastLED drivers ?

@coderabbitai
Copy link

coderabbitai bot commented Feb 11, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

echo "=== Check current I2S usage in codebase ==="
rg -n "I2S|i2s" --type-add 'code:*.{h,cpp,ino}' --type code -g '!*.json' -g '!*.md' -C 2

echo -e "\n\n=== Check for PIR sensor mentions ==="
rg -n "PIR|pir|SR602|motion" --type-add 'code:*.{h,cpp,ino}' --type code -C 2

echo -e "\n\n=== Check FastLED RMT configuration ==="
rg -n "FASTLED_RMT|RMT|FastLED" --type-add 'code:*.{h,cpp,ino}' --type code -C 2

echo -e "\n\n=== Check platformio.ini for build flags ==="
cat platformio.ini | grep -A 30 "build_flags"

Length of output: 114151


🌐 Web query:

ESP32 I2S microphone Arduino library INMP441 ICS43432

💡 Result:

For INMP441 and ICS-43432/ICS43432 on ESP32 + Arduino, you typically don’t need a mic-specific library—just the Arduino-ESP32 I2S API (or the underlying ESP-IDF I2S driver). (docs.espressif.com)

Recommended (current) option: Arduino-ESP32 I2S API

Arduino-ESP32 provides an I2S class with setPins() + begin() for standard I2S (STD) mode. (docs.espressif.com)

Minimal pattern:

`#include` <I2S.h>

void setup() {
  // bclk, ws(lrclk), dout, din, mclk
  I2S.setPins(26, 25, -1, 32, -1);
  I2S.begin(I2S_MODE_STD, 16000, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO);
}

void loop() {
  int32_t sample = I2S.read();
}

(Adjust pins/sample rate/bit-width for your board and mic.)

Lower-level option: ESP-IDF “new” I2S driver (i2s_std.h)

If you want maximum control/performance, use the ESP-IDF driver API (driver/i2s_std.h, etc.). (docs.espressif.com)

Avoid / be aware of: legacy driver/i2s.h

Many older INMP441 tutorials use #include <driver/i2s.h> (legacy). It can still be found in older examples, but Arduino-ESP32 has moved toward the newer APIs, and mixing “legacy” and “new” I2S drivers can cause conflicts. (dronebotworkshop.com)

Wiring notes (applies to both INMP441 and ICS-43432)

You wire BCLK/SCK, WS/LRCLK, and DOUT (mic) → DIN (ESP32); some mics have an L/R select pin to choose left vs right slot (often tie to GND for left). (docs.espressif.com)

Citations:


🌐 Web query:

ESP32 I2S RMT peripheral conflict FastLED DMA interference

💡 Result:

On ESP32, FastLED’s RMT and I2S “clockless” (LCD/parallel) drivers are both DMA-based and rely on timely ISR/buffer refills. “Conflicts” typically show up as sparkles/junk pixels, stalls, or timing corruption when something else delays those refills (other peripherals, flash operations, Wi‑Fi/BT, etc.). (reddit.com)

What’s actually conflicting

  • Flash/FS operations can pause the system: when ESP-IDF performs SPI flash operations, it disables caches and suspends other tasks; non‑IRAM-safe interrupts won’t run until flash ops finish. If your LED driver’s critical code/ISRs can’t run during that window, DMA underflows → corrupted LED waveform. (docs.espressif.com)
  • ESP-IDF 5 / Arduino-ESP32 core 3.x breaks FastLED’s classic ESP32 I2S LCD-mode approach: Espressif docs state LCD/Camera mode isn’t implemented by the I2S driver, and FastLED issue reports the legacy low-level approach is incompatible with the ESP-IDF 5 I2S redesign. (docs.espressif.com)
  • DMA-capable memory constraints: DMA buffers must be in internal DMA-capable RAM; if buffers land in PSRAM or non-DMA memory, the framework may copy/bounce-buffer (adding latency/jitter) or fail in edge cases. (github.com)

Practical mitigations (what usually works)

  1. Don’t run FastLED I2S and RMT at the same time (pick one output method for LEDs; avoid parallel “LED engine” + another timing-critical DMA-peripheral competing for ISR time).
  2. If you’re on Arduino-ESP32 core 3.x (ESP-IDF 5.x): prefer RMT (or FastLED’s SPI clockless WS2812 path) over the legacy I2S LCD driver, because I2S LCD-mode is effectively a mismatch with the new driver model. (github.com)
  3. Avoid flash activity during show(): don’t read SPIFFS/flash, don’t log heavily, don’t allocate, don’t do Wi‑Fi-heavy work right around FastLED.show(); flash ops can block non‑IRAM interrupts. (docs.espressif.com)
  4. Make DMA buffers explicitly DMA-capable (internal RAM / MALLOC_CAP_DMA style allocations) in any DMA-based LED/audio/display pipeline. (github.com)

If you tell me chip (ESP32 vs S3), Arduino core version (2.x vs 3.x), which FastLED output you’re using (RMT/I2S/SPI), and what the other peripheral is doing (I2S audio? RMT input? SPIFFS? Wi‑Fi?), I can suggest the most likely “known-good” combination and the least invasive change.

Citations:


@ewowi, Great questions! Let me address each sensor type and the I2S/FastLED interference concern:


📌 1. MY-SR602 PIR Sensor (Passive Infrared Motion Detector)

No bus protocol needed – it's just a digital GPIO input!

The MY-SR602 is a simple digital motion sensor that outputs:

  • HIGH (3.3V) when motion is detected
  • LOW (0V) when no motion

Implementation in ModuleIO.h

Add a new pin usage type:

// In ModuleIO.h pin usage enum:
enum PinUsage {
  // ... existing types ...
  pin_PIR,        // NEW: Passive Infrared Motion sensor
  // ... rest of types ...
};

Add to UI dropdown:

// In setupDefinition():
addControlValue(control, "PIR");

No centralized management needed – each PIR driver node will just read its assigned GPIO pin directly using digitalRead().

Create a PIR Driver Node

Create src/MoonLight/Nodes/Drivers/D_PIR.h:

`#if` FT_MOONLIGHT

class PIRDriver : public Node {
 public:
  static const char* name() { return "PIR Sensor"; }
  static uint8_t dim() { return _NoD; }
  static const char* tags() { return "👁️"; }

  bool motionDetected = false;
  bool lastState = false;
  uint8_t pinPIR = UINT8_MAX;
  uint32_t lastTriggerTime = 0;
  uint32_t cooldownMs = 2000;  // Configurable cooldown period

  void setup() override {
    addControl(motionDetected, "motion", "checkbox", 0, 1, true);  // read-only
    addControl(cooldownMs, "cooldown", "number", 100, 60000, false, "ms");
  }

  void onUpdate(const Char<20>& oldValue, const JsonObject& control) override {
    if (!control["on"].isNull()) {
      if (control["on"] == true) {
        // Find PIR pin from ModuleIO
        pinPIR = UINT8_MAX;
        for (JsonObject pinObject : modules->getModuleOfClass<ModuleIO>()->_state.data["pins"].as<JsonArray>()) {
          if (pinObject["usage"] == pin_PIR) {
            pinPIR = pinObject["GPIO"];
            break;
          }
        }
        
        if (pinPIR != UINT8_MAX) {
          pinMode(pinPIR, INPUT);
          EXT_LOGI(ML_TAG, "PIR sensor initialized on GPIO %d", pinPIR);
        } else {
          EXT_LOGW(ML_TAG, "No PIR pin assigned in IO configuration");
        }
      }
    }
  }

  void loop() override {
    if (pinPIR == UINT8_MAX) return;
    
    bool currentState = digitalRead(pinPIR);
    
    // Detect rising edge (motion started)
    if (currentState && !lastState) {
      motionDetected = true;
      lastTriggerTime = millis();
      
      // Update shared data or trigger effects
      // sharedData.pirTriggered = true;  // If you add this field to SharedData
      
      // Update UI
      JsonDocument doc;
      doc["motion"] = true;
      update(doc.as<JsonObject>(), ModuleState::update, _nodeName);
      
      EXT_LOGI(ML_TAG, "Motion detected!");
    }
    
    // Auto-reset after cooldown
    if (motionDetected && (millis() - lastTriggerTime > cooldownMs)) {
      motionDetected = false;
      
      JsonDocument doc;
      doc["motion"] = false;
      update(doc.as<JsonObject>(), ModuleState::update, _nodeName);
    }
    
    lastState = currentState;
  }
};

`#endif`

Register in ModuleDrivers.h:

addControlValue(control, getNameAndTags<PIRDriver>());
// ...
if (!node) node = checkAndAlloc<PIRDriver>(name);

📌 2. I2S Microphones (INMP441, ICS-43432/4)

Yes, centralize I2S bus management in ModuleIO.h (similar to I2C/SPI)

I2S microphones use the I2S bus protocol with these signals:

  • SD (Serial Data / DOUT from mic → DIN to ESP32)
  • WS (Word Select / LRCLK - left/right channel clock)
  • SCK (Bit Clock / BCLK)
  • MCLK (Master Clock - optional, some mics don't need it)

ModuleIO.h already has I2S pin types defined!

Looking at the code, ModuleIO.h already includes:

pin_I2S_SD,
pin_I2S_WS,
pin_I2S_SCK,
pin_I2S_MCLK,

And some board presets already assign them (e.g., board_SR for SR boards with mic support).

Add I2S Bus Initialization to ModuleIO.h

Similar to the I2C centralization, add I2S management:

// In ModuleIO.h, add includes at top:
`#include` <I2S.h>

// In setupDefinition():
addControl(controls, "i2sFreq", "number", 0, 96000, false, "Hz");  // Sample rate

control = addControl(controls, "i2sBus", "rows");
control["crud"] = "r";
rows = control["n"].to<JsonArray>();
{
  addControl(rows, "device", "text", 0, 32, true);  // ro - device type
  addControl(rows, "active", "checkbox", 0, 1, true);  // ro - is initialized
}

// Private members:
uint8_t pinI2SSD = UINT8_MAX;
uint8_t pinI2SWS = UINT8_MAX;
uint8_t pinI2SSCK = UINT8_MAX;
uint8_t pinI2SMCLK = UINT8_MAX;
bool i2sInitialized = false;

// Public methods:
bool isI2SInitialized() const { return i2sInitialized; }

Extend readPins() to initialize I2S:

void readPins() {
  // ... existing I2C code ...
  
  // I2S pin detection
  for (JsonObject pinObject : _state.data["pins"].as<JsonArray>()) {
    uint8_t usage = pinObject["usage"];
    if (usage == pin_I2S_SD) {
      pinI2SSD = pinObject["GPIO"];
      EXT_LOGD(ML_TAG, "I2S SD found %d", pinI2SSD);
    }
    if (usage == pin_I2S_WS) {
      pinI2SWS = pinObject["GPIO"];
      EXT_LOGD(ML_TAG, "I2S WS found %d", pinI2SWS);
    }
    if (usage == pin_I2S_SCK) {
      pinI2SSCK = pinObject["GPIO"];
      EXT_LOGD(ML_TAG, "I2S SCK found %d", pinI2SSCK);
    }
    if (usage == pin_I2S_MCLK) {
      pinI2SMCLK = pinObject["GPIO"];
      EXT_LOGD(ML_TAG, "I2S MCLK found %d", pinI2SMCLK);
    }
  }
  
  // Initialize I2S if all required pins are assigned
  if (pinI2SSD != UINT8_MAX && pinI2SWS != UINT8_MAX && pinI2SSCK != UINT8_MAX) {
    I2S.end();  // Clean up any previous I2S initialization
    
    // setPins(bclk, ws, dout, din, mclk)
    // For microphone: dout=-1 (we're not outputting), din=SD pin
    int8_t mclkPin = (pinI2SMCLK != UINT8_MAX) ? pinI2SMCLK : -1;
    I2S.setPins(pinI2SSCK, pinI2SWS, -1, pinI2SSD, mclkPin);
    
    uint32_t sampleRate = _state.data["i2sFreq"] | 16000;  // Default 16kHz
    
    // Begin in standard mode, 16-bit samples, mono (microphone)
    bool success = I2S.begin(I2S_MODE_STD, sampleRate, I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO);
    
    if (success) {
      i2sInitialized = true;
      EXT_LOGI(ML_TAG, "I2S bus initialized: sd:%d ws:%d sck:%d mclk:%d @ %d Hz", 
               pinI2SSD, pinI2SWS, pinI2SSCK, pinI2SMCLK, sampleRate);
      
      updateI2SDevices();
    } else {
      EXT_LOGW(ML_TAG, "I2S initialization failed");
    }
  }
}

void updateI2SDevices() {
  JsonDocument doc;
  doc["i2sBus"].to<JsonArray>();
  JsonObject newState = doc.as<JsonObject>();
  
  if (i2sInitialized) {
    JsonObject device = newState["i2sBus"].as<JsonArray>().add<JsonObject>();
    device["device"] = "I2S Microphone";
    device["active"] = true;
  }
  
  doc["i2sFreq"] = _state.data["i2sFreq"] | 16000;
  update(newState, ModuleState::update, _moduleName);
}

Create a Microphone Driver Node

Create src/MoonLight/Nodes/Drivers/D_Microphone.h:

`#if` FT_MOONLIGHT

`#include` <I2S.h>

class MicrophoneDriver : public Node {
 public:
  static const char* name() { return "Microphone"; }
  static uint8_t dim() { return _NoD; }
  static const char* tags() { return "🎤"; }

  uint16_t volumeLevel = 0;      // Current volume (0-1023)
  uint16_t volumePeak = 0;       // Peak volume
  uint16_t volumeAverage = 0;    // Running average
  bool soundDetected = false;
  uint16_t threshold = 100;      // Configurable threshold

  void setup() override {
    addControl(volumeLevel, "volume", "number", 0, 1023, true);      // read-only
    addControl(volumePeak, "peak", "number", 0, 1023, true);         // read-only
    addControl(volumeAverage, "average", "number", 0, 1023, true);   // read-only
    addControl(soundDetected, "sound", "checkbox", 0, 1, true);      // read-only
    addControl(threshold, "threshold", "number", 0, 1023, false);
  }

  void onUpdate(const Char<20>& oldValue, const JsonObject& control) override {
    if (!control["on"].isNull()) {
      if (control["on"] == true) {
        ModuleIO* io = modules->getModuleOfClass<ModuleIO>();
        if (io && io->isI2SInitialized()) {
          EXT_LOGI(ML_TAG, "Microphone driver activated");
        } else {
          EXT_LOGW(ML_TAG, "I2S not initialized by ModuleIO");
        }
      }
    }
  }

  void loop20ms() override {
    ModuleIO* io = modules->getModuleOfClass<ModuleIO>();
    if (!io || !io->isI2SInitialized()) return;
    
    // Read I2S samples
    int32_t sample = I2S.read();
    
    // Convert to absolute value and scale
    uint16_t magnitude = abs(sample) >> 16;  // Adjust scaling as needed
    
    volumeLevel = magnitude;
    
    // Update peak
    if (magnitude > volumePeak) {
      volumePeak = magnitude;
    } else {
      volumePeak = (volumePeak * 15 + magnitude) / 16;  // Slow decay
    }
    
    // Update running average
    volumeAverage = (volumeAverage * 15 + magnitude) / 16;
    
    // Sound detection
    soundDetected = (magnitude > threshold);
    
    // Store in shared data for effects to use
    // sharedData.audioLevel = volumeLevel;  // If you add this to SharedData
    
    // Update UI periodically (not every 20ms to avoid spam)
    static uint8_t updateCounter = 0;
    if (++updateCounter >= 25) {  // Update UI every 500ms
      updateCounter = 0;
      JsonDocument doc;
      doc["volume"] = volumeLevel;
      doc["peak"] = volumePeak;
      doc["average"] = volumeAverage;
      doc["sound"] = soundDetected;
      update(doc.as<JsonObject>(), ModuleState::update, _nodeName);
    }
  }
};

`#endif`

📌 3. Do I2S Microphones Interfere with I2S FastLED Drivers?

✅ Good news: NO direct conflict!

Why:

  1. FastLED uses RMT, not I2S (on modern ESP-IDF 5.x / Arduino-ESP32 3.x)

    • From your learning: "FastLED defaults to RMT5 driver (wraps Espressif's led_strip component)"
    • Your platformio.ini can force RMT4: -D FASTLED_RMT5=0
  2. RMT and I2S are different peripherals

    • RMT = Remote Control Transceiver (generates precise timing pulses for WS2812/SK6812 LEDs)
    • I2S = Inter-IC Sound (serial audio interface for microphones/speakers)
    • They use different hardware blocks on the ESP32
  3. The "I2S" in your D_FastLED.h is OLD/DEPRECATED

    • The FASTLED_USES_ESP32S3_I2S flag refers to FastLED's legacy I2S LCD parallel mode
    • This mode is not compatible with ESP-IDF 5.x and should not be used
    • Your code correctly shows bool usesI2S = false for modern builds

⚠️ Potential indirect issues (DMA bandwidth competition)

While RMT and I2S won't directly conflict, both are DMA-based, so:

Possible symptoms if both are heavily used:

  • LED timing glitches (sparkles/junk pixels)
  • Audio dropouts
  • Increased CPU load from DMA buffer management

Mitigations:

  1. Allocate DMA buffers in internal RAM (not PSRAM):

    buffer = (uint8_t*)heap_caps_malloc(size, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);
  2. Avoid flash operations during critical timing (especially during FastLED.show()):

    • Don't read SPIFFS/LittleFS
    • Minimize logging
    • Avoid heavy WiFi activity
  3. Pin FastLED and audio processing to different cores:

    // FastLED on Core 1 (APP_CPU)
    xTaskCreatePinnedToCore(ledTask, "LED", 4096, NULL, 1, NULL, 1);
    
    // Audio on Core 0 (PRO_CPU)
    xTaskCreatePinnedToCore(audioTask, "Audio", 4096, NULL, 1, NULL, 0);
  4. Reduce I2S sample rate if not needed:

    • Use 16kHz instead of 44.1kHz if acceptable for your use case
    • Lower rates = less DMA traffic

Summary Table:

Concern FastLED RMT + I2S Mic FastLED Old I2S LCD + I2S Mic
Direct peripheral conflict No conflict Conflict! Don't use!
DMA bandwidth competition ⚠️ Possible under heavy load ⚠️ Yes, both use I2S DMA
Recommended? Safe combination Avoid - use RMT instead

📋 Action Items Summary

For PIR Sensors:

  1. ✅ Add pin_PIR usage type to ModuleIO.h
  2. ✅ Create D_PIR.h driver (simple GPIO input reading)
  3. ✅ Register in ModuleDrivers.h

For I2S Microphones:

  1. ✅ Centralize I2S bus management in ModuleIO.h (similar to I2C/SPI)
  2. ✅ Initialize I2S bus when pins are assigned
  3. ✅ Create D_Microphone.h driver
  4. ✅ Add audio level to SharedData for effects to use
  5. ✅ Consider adding FFT analysis if you want frequency-reactive effects

Interference Mitigation:

  1. ✅ Verify you're using FastLED RMT mode (not I2S LCD mode)
  2. ✅ Allocate DMA buffers in internal RAM
  3. ✅ Minimize flash/WiFi activity during LED updates
  4. ✅ Consider dual-core task pinning for heavy loads

Would you like me to generate the complete implementation code for either the PIR driver or the I2S microphone driver?


🧠 Learnings used
Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-01-26T14:59:22.091Z
Learning: In MoonModules/MoonLight PR `#114`: ModuleDevices deadlock caused by: (1) StatefulService.h line 204-205 bug - after 500ms mutex timeout, continues WITHOUT acquiring mutex, causing race conditions and JSON corruption. (2) receiveUDP() while-loop processes own UDP broadcasts via loopback, creating circular call chain: sendUDP→broadcast→receiveUDP→updateDevices→update→handlers→sendUDP. (3) updateDevices() holds _accessMutex during expensive sort/JSON operations with 15 devices, can exceed 500ms with nested calls. Solution: Fix StatefulService to wait forever or reboot on timeout; skip own broadcasts by checking message.name == ownHostname; optimize updateDevices to sort outside mutex.

Learnt from: ewowi
Repo: MoonModules/MoonLight PR: 0
File: :0-0
Timestamp: 2026-02-05T13:20:45.988Z
Learning: In MoonLight with FastLED 3.11 on ESP-IDF 5.x+ (Arduino core 3.x): FastLED defaults to RMT5 driver (wraps Espressif's led_strip component), which has known ISR handler lifecycle issues causing repeated "Handler detached" messages every ~500ms and severe performance degradation (e.g., 2 FPS). RMT5 is more prone to Wi-Fi/interrupt jitter than legacy RMT4. Solution: force legacy RMT4 by adding `-D FASTLED_RMT5=0` to build flags in platformio.ini, or switch to FastLED's parallel I/O drivers which don't use RMT. RMT4 and RMT5 cannot coexist in the same binary.

ModuleIO: add I2C pins for default boards per MCU type, reset pins before update

IMU Driver: fix accell calculation, BMI160 code (commented ATM)
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@src/MoonBase/Modules/ModuleIO.h`:
- Around line 578-601: Remove the redundant initial I2C pin assignment block
that calls pinAssigner.assignPin(21, pin_I2C_SDA)/assignPin(22, pin_I2C_SCL)
(the block guarded by `#ifdef` CONFIG_IDF_TARGET_ESP32 at the top) because
setBoardPresetDefaults() already performs target-specific assignments; keep the
later granular conditional block (the `#ifdef` / `#elif` chain that includes
CONFIG_IDF_TARGET_ESP32, ESP32S2/S3/C3, ESP32C6, ESP32P4, and the fallback) and
delete the earlier duplicate assignments so pinAssigner.assignPin is only
invoked once per board preset.
- Around line 848-859: The i2cFreq control currently defaults to 0 and leads to
calling Wire.begin(pinI2CSDA, pinI2CSCL, 0) and showing "0 kHz" in the UI;
update the setupDefinition to provide a sensible default (e.g., 100) for i2cFreq
and add a guard in the code that reads _state.data["i2cFreq"] (the
readPins/initI2C area) to treat 0 as the default value (replace with 100 or
another chosen default) before multiplying by 1000 and passing to
Wire.begin(pinI2CSDA, pinI2CSCL, frequency * 1000); ensure any logged frequency
uses the adjusted value so the UI shows a nonzero kHz and maintain behavior that
falls back to a safe clock when frequency is absent or zero.

In `@src/MoonLight/Nodes/Drivers/D_IMU.h`:
- Around line 29-35: The board selection control (added in setup via
addControl(board,...)) can flip at runtime without clearing state, so update
handling must reset motion-tracking state; add a guard in onUpdate (or a change
callback for the "board" control) that detects when board changes and sets
motionTrackingReady = false and disables/re-initializes motion tracking (e.g.,
set isEnabled = false or call the MPU6050 teardown/init sequence) so loop20ms's
BMI160 branch cannot run while MPU6050 state remains valid; reference
functions/vars: setup, addControl(board,...), board, onUpdate, loop20ms,
motionTrackingReady, isEnabled (or the MPU init/teardown helpers) to locate
where to implement this reset.
- Around line 38-88: Add a cleanup path for when the driver is turned off by
modifying onUpdate in D_IMU: when control["on"] == false (or control["on"] is
present and false) call the MPU shutdown steps—disable the DMP via
mpu.setDMPEnabled(false) (or equivalent), clear any interrupt/status flags, stop
any periodic reads, and set motionTrackingReady = false; also ensure any
board==1 BMI160 cleanup is mirrored. Then implement the same teardown in the
D_IMU destructor (~D_IMU) to disable DMP/interrupts and reset
motionTrackingReady so loop20ms stops reading after node removal. Ensure you
reference the existing symbols onUpdate, motionTrackingReady, mpu.setDMPEnabled
(and ~D_IMU) when making changes.
🧹 Nitpick comments (2)
src/MoonLight/Nodes/Drivers/D_IMU.h (1)

109-118: Stale comments around the accelerometer read — clean up for clarity.

  • Line 109: "display real acceleration, adjusted to remove gravity" is an orphaned comment (no display code follows it).
  • Line 113: "needed to repeat the following 3 lines" is misleading since only dmpGetAccel (line 115) is active; the other two remain commented out.

Consider removing these leftover comments to avoid confusing future readers about why certain calls are or aren't needed.

src/MoonBase/Modules/ModuleIO.h (1)

862-863: I2C pin members are public — verify this is intentional.

pinI2CSDA and pinI2CSCL are declared in the public section, presumably so D_IMU.h and other drivers can query them. If external read access is the intent, this is fine; consider adding a brief comment to document the intended usage.

Comment on lines +578 to +601
#ifdef CONFIG_IDF_TARGET_ESP32
pinAssigner.assignPin(21, pin_I2C_SDA);
pinAssigner.assignPin(22, pin_I2C_SCL);
#else
pinAssigner.assignPin(8, pin_I2C_SDA);
pinAssigner.assignPin(9, pin_I2C_SCL);
#endif
// In setBoardPresetDefaults() for board_none (default case):
#ifdef CONFIG_IDF_TARGET_ESP32
pinAssigner.assignPin(21, pin_I2C_SDA); // ESP32 classic
pinAssigner.assignPin(22, pin_I2C_SCL);
#elif defined(CONFIG_IDF_TARGET_ESP32S2) || defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C3)
pinAssigner.assignPin(8, pin_I2C_SDA); // ESP32-C3
pinAssigner.assignPin(9, pin_I2C_SCL);
#elif defined(CONFIG_IDF_TARGET_ESP32C6)
pinAssigner.assignPin(23, pin_I2C_SDA); // ESP32-C6
pinAssigner.assignPin(22, pin_I2C_SCL);
#elif defined(CONFIG_IDF_TARGET_ESP32P4)
pinAssigner.assignPin(7, pin_I2C_SDA); // ESP32-P4 (common board default)
pinAssigner.assignPin(8, pin_I2C_SCL);
#else
pinAssigner.assignPin(21, pin_I2C_SDA); // Fallback
pinAssigner.assignPin(22, pin_I2C_SCL);
#endif
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Duplicate I2C pin assignments — first block is dead code.

Lines 578–584 assign I2C pins, then lines 586–601 immediately overwrite them with a more granular per-target mapping. The first block is redundant. For CONFIG_IDF_TARGET_ESP32, pins 21/22 are assigned twice; for other targets (e.g., C6), the first assignment (8/9) is silently overwritten.

Proposed fix — remove the first block
-  `#ifdef` CONFIG_IDF_TARGET_ESP32
-      pinAssigner.assignPin(21, pin_I2C_SDA);
-      pinAssigner.assignPin(22, pin_I2C_SCL);
-  `#else`
-      pinAssigner.assignPin(8, pin_I2C_SDA);
-      pinAssigner.assignPin(9, pin_I2C_SCL);
-  `#endif`
-  // In setBoardPresetDefaults() for board_none (default case):
   `#ifdef` CONFIG_IDF_TARGET_ESP32
       pinAssigner.assignPin(21, pin_I2C_SDA);  // ESP32 classic
       pinAssigner.assignPin(22, pin_I2C_SCL);
🤖 Prompt for AI Agents
In `@src/MoonBase/Modules/ModuleIO.h` around lines 578 - 601, Remove the redundant
initial I2C pin assignment block that calls pinAssigner.assignPin(21,
pin_I2C_SDA)/assignPin(22, pin_I2C_SCL) (the block guarded by `#ifdef`
CONFIG_IDF_TARGET_ESP32 at the top) because setBoardPresetDefaults() already
performs target-specific assignments; keep the later granular conditional block
(the `#ifdef` / `#elif` chain that includes CONFIG_IDF_TARGET_ESP32, ESP32S2/S3/C3,
ESP32C6, ESP32P4, and the fallback) and delete the earlier duplicate assignments
so pinAssigner.assignPin is only invoked once per board preset.

Comment on lines +848 to +859
if (pinI2CSCL != UINT8_MAX && pinI2CSDA != UINT8_MAX) {
Wire.end(); // Clean up any previous I2C initialization
delay(100);
uint16_t frequency = _state.data["i2cFreq"];
if (Wire.begin(pinI2CSDA, pinI2CSCL, frequency * 1000)) {
EXT_LOGI(ML_TAG, "initI2C Wire sda:%d scl:%d freq:%d kHz", pinI2CSDA, pinI2CSCL, frequency);
// delay(200); // Give I2C bus time to stabilize
// Wire.setClock(50000); // Explicitly set to 100kHz
updateDevices();
} else
EXT_LOGE(ML_TAG, "initI2C Wire failed");
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Zero i2cFreq default will pass 0 Hz to Wire.begin().

The i2cFreq control has no "default" set in setupDefinition (line 222), so it defaults to 0. On line 852, frequency * 1000 evaluates to 0, and Wire.begin(sda, scl, 0) is called. While Arduino-ESP32's Wire.begin may internally fall back to 100 kHz when given 0, this behavior is implicit and undocumented. A user seeing "0 kHz" in the UI is also confusing.

Proposed fix — add a default and guard against zero

In setupDefinition, add a default:

-    addControl(controls, "i2cFreq", "number", 0, 65534, false, "kHz");
+    control = addControl(controls, "i2cFreq", "number", 10, 1000, false, "kHz");
+    control["default"] = 100;  // 100 kHz standard mode

And in readPins, guard:

     uint16_t frequency = _state.data["i2cFreq"];
+    if (frequency == 0) frequency = 100;  // fallback to 100 kHz
     if (Wire.begin(pinI2CSDA, pinI2CSCL, frequency * 1000)) {
🤖 Prompt for AI Agents
In `@src/MoonBase/Modules/ModuleIO.h` around lines 848 - 859, The i2cFreq control
currently defaults to 0 and leads to calling Wire.begin(pinI2CSDA, pinI2CSCL, 0)
and showing "0 kHz" in the UI; update the setupDefinition to provide a sensible
default (e.g., 100) for i2cFreq and add a guard in the code that reads
_state.data["i2cFreq"] (the readPins/initI2C area) to treat 0 as the default
value (replace with 100 or another chosen default) before multiplying by 1000
and passing to Wire.begin(pinI2CSDA, pinI2CSCL, frequency * 1000); ensure any
logged frequency uses the adjusted value so the UI shows a nonzero kHz and
maintain behavior that falls back to a safe clock when frequency is absent or
zero.

Comment on lines +29 to +35
void setup() override {
addControl(gyro, "gyro", "coord3D");
addControl(accell, "accell", "coord3D");
// isEnabled = false; // need to enable after fresh setup
addControl(board, "board", "select");
addControlValue("MPU6050");
addControlValue("BMI160"); // not supported yet
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

board selection has no runtime guard — switching board type while running could cause issues.

If a user changes board from 0 (MPU6050) to 1 (BMI160) at runtime without re-triggering onUpdate, loop20ms will enter the BMI160 branch (which is a no-op) while motionTrackingReady is still true from the MPU6050 init. Consider resetting motionTrackingReady when board changes, or at minimum documenting that a re-enable is required.

🤖 Prompt for AI Agents
In `@src/MoonLight/Nodes/Drivers/D_IMU.h` around lines 29 - 35, The board
selection control (added in setup via addControl(board,...)) can flip at runtime
without clearing state, so update handling must reset motion-tracking state; add
a guard in onUpdate (or a change callback for the "board" control) that detects
when board changes and sets motionTrackingReady = false and
disables/re-initializes motion tracking (e.g., set isEnabled = false or call the
MPU6050 teardown/init sequence) so loop20ms's BMI160 branch cannot run while
MPU6050 state remains valid; reference functions/vars: setup,
addControl(board,...), board, onUpdate, loop20ms, motionTrackingReady, isEnabled
(or the MPU init/teardown helpers) to locate where to implement this reset.

Comment on lines +38 to +88
void onUpdate(const Char<20>& oldValue, const JsonObject& control) override {
// add your custom onUpdate code here
if (!control["on"].isNull()) { // control is the node n case of on!
if (control["on"] == true) {
bool i2cInited = true; // todo: check in moduleIO if successfull
if (i2cInited) {
if (board == 0) { // MPU6050
mpu.initialize();

// delay(100);

// verify connection
if (mpu.testConnection()) {
EXT_LOGI(ML_TAG, "MPU6050 connection successful Initializing DMP...");
uint8_t devStatus = mpu.dmpInitialize();

if (devStatus == 0) {
// // Calibration Time: generate offsets and calibrate our MPU6050
mpu.CalibrateAccel(6);
mpu.CalibrateGyro(6);
// mpu.PrintActiveOffsets();

mpu.setDMPEnabled(true); // mandatory

// mpuIntStatus = mpu.getIntStatus();

motionTrackingReady = true;
} else {
// ERROR!
// 1 = initial memory load failed
// 2 = DMP configuration updates failed
// (if it's going to break, usually the code will be 1)
EXT_LOGW(ML_TAG, "DMP Initialization failed (code %d)", devStatus);
}
} else
EXT_LOGW(ML_TAG, "Testing device connections MPU6050 connection failed");
} else if (board == 1) { // BMI160 - NEW

// BMI160.begin(BMI160GenClass::I2C_MODE, 0x68);

// if (BMI160.getDeviceID() == 0xD1) { // BMI160 device ID
// EXT_LOGI(ML_TAG, "BMI160 connection successful");
// motionTrackingReady = true;
// } else {
// EXT_LOGW(ML_TAG, "BMI160 connection failed");
// }
}
}
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

No cleanup path when the driver is turned off — DMP left running, motionTrackingReady never reset.

When control["on"] == false (or when the node is destroyed), the DMP remains enabled and motionTrackingReady stays true, so loop20ms keeps reading. Add an else branch to disable DMP and reset state, and mirror that in the destructor.

Proposed fix sketch
       if (control["on"] == true) {
         // ... existing init code ...
+      } else {
+        if (motionTrackingReady) {
+          mpu.setDMPEnabled(false);
+          motionTrackingReady = false;
+        }
       }

And in the destructor:

-  ~IMUDriver() override {};
+  ~IMUDriver() override {
+    if (motionTrackingReady) {
+      mpu.setDMPEnabled(false);
+      motionTrackingReady = false;
+    }
+  };
🤖 Prompt for AI Agents
In `@src/MoonLight/Nodes/Drivers/D_IMU.h` around lines 38 - 88, Add a cleanup path
for when the driver is turned off by modifying onUpdate in D_IMU: when
control["on"] == false (or control["on"] is present and false) call the MPU
shutdown steps—disable the DMP via mpu.setDMPEnabled(false) (or equivalent),
clear any interrupt/status flags, stop any periodic reads, and set
motionTrackingReady = false; also ensure any board==1 BMI160 cleanup is
mirrored. Then implement the same teardown in the D_IMU destructor (~D_IMU) to
disable DMP/interrupts and reset motionTrackingReady so loop20ms stops reading
after node removal. Ensure you reference the existing symbols onUpdate,
motionTrackingReady, mpu.setDMPEnabled (and ~D_IMU) when making changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants